kicksdk-js 0.0.0-stage → 0.2.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/index.js ADDED
@@ -0,0 +1,499 @@
1
+ // kicksdk for JavaScript: the WebAssembly build of the Rust core, plus
2
+ // connectChat(), which drives the realtime protocol with fetch, WebSocket and
3
+ // timers.
4
+ //
5
+ // Call `await init()` once before anything else (in Node, or anywhere the .wasm
6
+ // cannot be fetched beside this file, pass its bytes: `init(bytes)`).
7
+ //
8
+ // Where it works: Kick's realtime server only takes a socket whose Origin is
9
+ // https://kick.com. Node sets that header itself (see `socketFactory` below); a
10
+ // browser cannot, so in a browser connectChat only works on kick.com - a page
11
+ // there, or an extension's content script. Elsewhere, relay through a server.
12
+
13
+ import initWasm, {
14
+ initSync,
15
+ Session,
16
+ Backoff,
17
+ chatChannels,
18
+ chatConnectionRequest,
19
+ connectionRequest,
20
+ parseConnection,
21
+ tokenRequest,
22
+ parseToken,
23
+ retryAfterMs
24
+ } from './pkg/kicksdk.js';
25
+
26
+ export * from './pkg/kicksdk.js';
27
+
28
+ export const REQUIRED_ORIGIN = 'https://kick.com';
29
+
30
+ let ready = null;
31
+
32
+ /**
33
+ * Loads the WebAssembly. Safe to call more than once; if loading fails, the
34
+ * next call tries again.
35
+ */
36
+ export function init(input) {
37
+ if (!ready) {
38
+ const bytes =
39
+ input instanceof Uint8Array ||
40
+ input instanceof ArrayBuffer ||
41
+ (typeof WebAssembly !== 'undefined' && input instanceof WebAssembly.Module);
42
+ if (bytes) {
43
+ try {
44
+ initSync({ module: input });
45
+ } catch (e) {
46
+ return Promise.reject(e);
47
+ }
48
+ ready = Promise.resolve();
49
+ } else {
50
+ // A failed load (a fetch that did not get through) is not kept: a
51
+ // later init() starts over instead of returning the same rejection.
52
+ const loading = initWasm(input === undefined ? undefined : { module_or_path: input }).then(
53
+ () => undefined,
54
+ (e) => {
55
+ if (ready === loading) ready = null;
56
+ throw e;
57
+ }
58
+ );
59
+ ready = loading;
60
+ }
61
+ }
62
+ return ready;
63
+ }
64
+
65
+ const isNode = typeof process !== 'undefined' && !!(process.versions && process.versions.node);
66
+
67
+ /** How long one step of connecting (a fetch, the WebSocket handshake) may take. */
68
+ const DEFAULT_TIMEOUT_MS = 10_000;
69
+
70
+ /** The longest wait setTimeout holds; a longer one fires at once. */
71
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1;
72
+
73
+ const NO_WEBSOCKET =
74
+ 'no WebSocket in this runtime (Node 22 and later have one): pass connectChat a `WebSocket` ' +
75
+ "constructor (the `ws` package's will do) or `createSocket`";
76
+
77
+ const noop = () => {};
78
+
79
+ const message = (e) => String((e && e.message) || e);
80
+
81
+ /** A clock that only goes forward, so a wall-clock jump cannot fake silence. */
82
+ function monotonicClock() {
83
+ const p = globalThis.performance;
84
+ return p && typeof p.now === 'function' ? () => p.now() : () => Date.now();
85
+ }
86
+
87
+ /**
88
+ * The session's clock, whole ms: the caller's `now`, or performance.now().
89
+ * A reading of the caller's that is not a number from 0 to
90
+ * Number.MAX_SAFE_INTEGER (or a `now` that throws) is reported once, and the
91
+ * clock goes on with performance.now() from the last good reading: the
92
+ * session could do nothing with it, and every attempt would fail the same way.
93
+ */
94
+ function sessionClock(given, report) {
95
+ const mono = monotonicClock();
96
+ if (!given) return () => Math.floor(mono());
97
+ let last = 0;
98
+ let fallback = null;
99
+ return () => {
100
+ if (!fallback) {
101
+ let bad;
102
+ try {
103
+ const t = given();
104
+ if (typeof t === 'number' && t >= 0 && t <= Number.MAX_SAFE_INTEGER) return (last = Math.floor(t));
105
+ bad = new RangeError(`connectChat: now() returned ${String(t)}, not milliseconds; using performance.now()`);
106
+ } catch (e) {
107
+ bad = e;
108
+ }
109
+ report(bad);
110
+ const base = last;
111
+ const start = mono();
112
+ fallback = () => base + Math.floor(mono() - start);
113
+ }
114
+ return fallback();
115
+ };
116
+ }
117
+
118
+ /** A guest client id, a v4 UUID. Not a secret: it names the connection. */
119
+ function guestId() {
120
+ const c = globalThis.crypto;
121
+ // randomUUID is missing outside secure contexts, and Node 18 has no
122
+ // global crypto at all.
123
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
124
+ const b = new Uint8Array(16);
125
+ if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b);
126
+ else for (let i = 0; i < b.length; i++) b[i] = Math.floor(Math.random() * 256);
127
+ b[6] = (b[6] & 0x0f) | 0x40;
128
+ b[8] = (b[8] & 0x3f) | 0x80;
129
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
130
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
131
+ }
132
+
133
+ function socketFactory(o) {
134
+ if (o.createSocket) return (url) => o.createSocket(url);
135
+ const WS = o.WebSocket || globalThis.WebSocket;
136
+ if (typeof WS !== 'function') return null;
137
+ // Node's WebSocket (undici) and the `ws` package take headers; a browser's
138
+ // would read the object as a subprotocol, and sends its page's origin
139
+ // whatever it is given.
140
+ return isNode ? (url) => new WS(url, { headers: { Origin: REQUIRED_ORIGIN } }) : (url) => new WS(url);
141
+ }
142
+
143
+ /**
144
+ * Connects to one channel's chat and keeps the connection up until close().
145
+ * Whatever ends a connection, connectChat waits and connects again; after one
146
+ * of Kick's terminal closes (a refused token, a ban, a connection limit) the
147
+ * wait is long - 5, 10, 20, then 30 minutes in a row, see KICK.md §6.1 - and
148
+ * each new connection is a new guest client.
149
+ *
150
+ * Callbacks are isolated: one that throws is reported to `onError` (or
151
+ * console.error) and the connection carries on. None is called after
152
+ * close(), which may be called from inside one.
153
+ *
154
+ * @param {object} o ChatOptions, in index.d.ts
155
+ * @returns {{ close(): void }}
156
+ */
157
+ export function connectChat(o) {
158
+ if (!o || typeof o.onEvent !== 'function') throw new TypeError('connectChat: onEvent is required');
159
+ for (const name of ['onState', 'onDrop', 'onError', 'fetch', 'createSocket', 'WebSocket', 'now']) {
160
+ if (o[name] != null && typeof o[name] !== 'function') {
161
+ throw new TypeError(`connectChat: ${name} must be a function`);
162
+ }
163
+ }
164
+ const timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
165
+ // setTimeout fires a longer wait (Infinity among them) at once: every
166
+ // request would time out before it began, and nothing would connect.
167
+ if (typeof timeoutMs !== 'number' || !(timeoutMs > 0 && timeoutMs <= MAX_TIMEOUT_MS)) {
168
+ throw new RangeError(`connectChat: timeoutMs must be a number of ms above 0, at most ${MAX_TIMEOUT_MS}`);
169
+ }
170
+ // Ids and endpoints are checked here, where a mistake throws, rather than
171
+ // failing every reconnect.
172
+ // A feed listed twice is subscribed once, and reported once.
173
+ const channels = [...new Set(o.channels ?? chatChannels(o.chatroomId, o.channelId))];
174
+ const hasChannel = o.channelId !== undefined && o.channelId !== null;
175
+ if (hasChannel) chatConnectionRequest(o.channelId, 'x', o.endpoints);
176
+ else connectionRequest('x', o.endpoints);
177
+
178
+ const fetchImpl = o.fetch || ((...a) => globalThis.fetch(...a));
179
+ const createSocket = socketFactory(o);
180
+ // It would fail the same way on every attempt.
181
+ if (!createSocket) throw new TypeError(`connectChat: ${NO_WEBSOCKET}`);
182
+ const now = sessionClock(o.now, (e) => report(e));
183
+
184
+ let session = new Session(channels);
185
+ let backoff = new Backoff();
186
+ let closed = false; // close() was called
187
+ let ws = null;
188
+ let attempt = null; // aborts the current connection's fetches
189
+ let clientId = null;
190
+ let timer = null; // session.deadline()
191
+ let handshake = null;
192
+ let retry = null;
193
+
194
+ function report(error) {
195
+ if (o.onError) {
196
+ try {
197
+ o.onError(error);
198
+ return;
199
+ } catch (e) {
200
+ error = e;
201
+ }
202
+ }
203
+ console.error('kicksdk connectChat:', error);
204
+ }
205
+
206
+ /** A user callback, by name: never after close(), and never fatal. */
207
+ function callback(name, ...args) {
208
+ const fn = o[name];
209
+ if (closed || typeof fn !== 'function') return;
210
+ try {
211
+ fn.apply(o, args);
212
+ } catch (e) {
213
+ report(e);
214
+ }
215
+ }
216
+
217
+ /** An entry point (a socket event, a timer): a failure ends the connection, not the driver. */
218
+ function guard(fn) {
219
+ return (...args) => {
220
+ try {
221
+ return fn(...args);
222
+ } catch (e) {
223
+ report(e);
224
+ if (!closed) end(`kicksdk: ${message(e)}`, 0, 0, false);
225
+ }
226
+ };
227
+ }
228
+
229
+ /** A fetch that gives up after timeoutMs, or when `signal` aborts. */
230
+ function request(req, what, signal) {
231
+ return new Promise((resolve, reject) => {
232
+ const ctrl = new AbortController();
233
+ const cleanup = () => {
234
+ clearTimeout(timeout);
235
+ signal.removeEventListener('abort', onAbort);
236
+ };
237
+ const fail = (e) => {
238
+ cleanup();
239
+ ctrl.abort(e);
240
+ reject(e);
241
+ };
242
+ const onAbort = () => fail(new Error(`${what}: cancelled`));
243
+ const timeout = setTimeout(() => fail(new Error(`${what}: no answer in ${timeoutMs} ms`)), timeoutMs);
244
+ if (signal.aborted) return onAbort();
245
+ signal.addEventListener('abort', onAbort);
246
+ (async () => {
247
+ // A browser drops the headers it forbids (origin, referer) and sends its own.
248
+ const res = await fetchImpl(req.url, {
249
+ method: req.method,
250
+ headers: Object.fromEntries(req.headers),
251
+ body: req.body ?? undefined,
252
+ credentials: 'omit',
253
+ signal: ctrl.signal
254
+ });
255
+ return { status: res.status, body: await res.text() };
256
+ })().then(
257
+ (r) => {
258
+ cleanup();
259
+ resolve(r);
260
+ },
261
+ (e) => {
262
+ cleanup();
263
+ reject(e);
264
+ }
265
+ );
266
+ });
267
+ }
268
+
269
+ function state() {
270
+ if (closed) return;
271
+ callback('onState', {
272
+ connected: session.isConnected(),
273
+ subscribed: channels.filter((c) => session.isSubscribed(c))
274
+ });
275
+ }
276
+
277
+ function arm() {
278
+ clearTimeout(timer);
279
+ timer = null;
280
+ if (closed) return;
281
+ const at = session.deadline();
282
+ if (at === undefined || at === null) return;
283
+ timer = setTimeout(
284
+ guard(() => {
285
+ timer = null;
286
+ if (!closed) apply(session.tick(now()));
287
+ }),
288
+ // A clock that jumped could ask for more than setTimeout holds; a tick
289
+ // before the deadline does nothing, and arms the rest.
290
+ Math.min(MAX_TIMEOUT_MS, Math.max(0, at - now()))
291
+ );
292
+ }
293
+
294
+ function apply(actions) {
295
+ let changed = false;
296
+ for (const a of actions) {
297
+ // A callback may have called close().
298
+ if (closed) return;
299
+ switch (a.action) {
300
+ case 'send':
301
+ if (ws && ws.readyState === 1) ws.send(a.text);
302
+ break;
303
+ case 'close':
304
+ // Kick said goodbye, or went quiet. The socket is finished now: a
305
+ // dead connection may not deliver its close event for minutes.
306
+ end(a.reason, a.code, a.retry_after_ms, a.terminal);
307
+ return;
308
+ case 'refresh_token':
309
+ refresh();
310
+ break;
311
+ case 'subscribed':
312
+ // The chat feed (the first listed) is up: back to short waits, and
313
+ // a terminal streak is over.
314
+ if (a.channel === channels[0]) backoff.reset();
315
+ changed = true;
316
+ break;
317
+ case 'connected':
318
+ // With no feed to subscribe to, the connection is all there is.
319
+ if (channels.length === 0) backoff.reset();
320
+ changed = true;
321
+ break;
322
+ case 'unsubscribed':
323
+ case 'subscribe_failed':
324
+ changed = true;
325
+ break;
326
+ case 'event':
327
+ callback('onEvent', { channel: a.channel, name: a.name, data: a.data, event: a.event });
328
+ break;
329
+ }
330
+ }
331
+ if (changed) state();
332
+ arm();
333
+ }
334
+
335
+ function refresh() {
336
+ const ctrl = attempt;
337
+ if (!ctrl) return;
338
+ request(tokenRequest(clientId, o.endpoints), 'token refresh', ctrl.signal)
339
+ .then((r) => parseToken(r.status, r.body))
340
+ .then(
341
+ guard((token) => {
342
+ if (!closed && attempt === ctrl) apply(session.refresh(token, now()));
343
+ }),
344
+ // Kick ends the connection when the old token runs out; the
345
+ // reconnect fetches a new one.
346
+ noop
347
+ );
348
+ }
349
+
350
+ /** Lets go of the socket, without waiting for it, and of what hangs on it. */
351
+ function detach() {
352
+ clearTimeout(timer);
353
+ clearTimeout(handshake);
354
+ timer = handshake = null;
355
+ if (attempt) {
356
+ attempt.abort();
357
+ attempt = null;
358
+ }
359
+ const s = ws;
360
+ ws = null;
361
+ if (s) {
362
+ s.onopen = s.onmessage = s.onclose = null;
363
+ // The `ws` package emits an error on closing a socket still
364
+ // connecting, and throws one that nobody listens to.
365
+ s.onerror = noop;
366
+ try {
367
+ s.close();
368
+ } catch {
369
+ // Closed already.
370
+ }
371
+ }
372
+ }
373
+
374
+ function release() {
375
+ if (session) session.free();
376
+ if (backoff) backoff.free();
377
+ session = backoff = null;
378
+ }
379
+
380
+ /**
381
+ * A connection, or an attempt at one, ended: wait, then connect again.
382
+ * `retryAfterMs` is the least wait the end asked for (0: the ordinary
383
+ * backoff); `terminal`, that Kick will not take this client back.
384
+ */
385
+ function drop(reason, code, retryAfterMs, terminal) {
386
+ if (closed) return;
387
+ const waitMs = Math.min(backoff.nextDelayFor(retryAfterMs, Math.random()), MAX_TIMEOUT_MS);
388
+ callback('onDrop', reason, { code, terminal, waitMs });
389
+ if (closed) return;
390
+ clearTimeout(retry);
391
+ // connect() makes a new client id (so a new guest token) every time,
392
+ // which a terminal close needs; after any other end it does no harm.
393
+ retry = setTimeout(start, waitMs);
394
+ }
395
+
396
+ /** The socket is finished, however it ended. */
397
+ function end(reason, code, retryAfterMs, terminal) {
398
+ detach();
399
+ if (closed) return;
400
+ session.closed();
401
+ state();
402
+ drop(reason, code, retryAfterMs, terminal);
403
+ }
404
+
405
+ function start() {
406
+ retry = null;
407
+ connect().catch((e) => {
408
+ report(e);
409
+ if (!closed) end(`kicksdk: ${message(e)}`, 0, 0, false);
410
+ });
411
+ }
412
+
413
+ async function connect() {
414
+ if (closed) return;
415
+ const ctrl = new AbortController();
416
+ attempt = ctrl;
417
+ const mine = () => !closed && attempt === ctrl;
418
+ let url;
419
+ let token;
420
+ try {
421
+ // A guest identity: the token is issued to this id, and so is every
422
+ // refreshed one for this connection.
423
+ clientId = guestId();
424
+ const where = hasChannel
425
+ ? chatConnectionRequest(o.channelId, clientId, o.endpoints)
426
+ : connectionRequest(clientId, o.endpoints);
427
+ const c = await request(where, 'connection', ctrl.signal);
428
+ if (!mine()) return;
429
+ const found = parseConnection(c.status, c.body).find((x) => x.provider === 'centrifugo');
430
+ if (!found) throw new Error('Kick offered no Centrifugo server');
431
+ url = found.url;
432
+ const t = await request(tokenRequest(clientId, o.endpoints), 'token', ctrl.signal);
433
+ if (!mine()) return;
434
+ token = parseToken(t.status, t.body);
435
+ } catch (e) {
436
+ // Closed meanwhile: nobody is left to tell.
437
+ if (!mine()) return;
438
+ attempt = null;
439
+ drop(message(e), 0, 0, false);
440
+ return;
441
+ }
442
+
443
+ let socket;
444
+ try {
445
+ socket = createSocket(url);
446
+ } catch (e) {
447
+ attempt = null;
448
+ drop(`could not open a WebSocket: ${message(e)}`, 0, 0, false);
449
+ return;
450
+ }
451
+ ws = socket;
452
+ handshake = setTimeout(
453
+ guard(() => {
454
+ handshake = null;
455
+ if (ws === socket) end(`WebSocket handshake: no answer in ${timeoutMs} ms`, 0, 0, false);
456
+ }),
457
+ timeoutMs
458
+ );
459
+ socket.onopen = guard(() => {
460
+ if (ws !== socket) return;
461
+ clearTimeout(handshake);
462
+ handshake = null;
463
+ apply(session.open(token, now()));
464
+ });
465
+ socket.onmessage = guard((e) => {
466
+ if (ws === socket) apply(session.receive(String(e.data), now()));
467
+ });
468
+ socket.onclose = guard((e) => {
469
+ if (ws !== socket) return;
470
+ // A refused handshake ends here too, with no open before it. A close
471
+ // without a code a peer can send (1000-4999) is 1006, abnormal
472
+ // closure: Node 22's WebSocket reports a refused handshake as 0.
473
+ const code = e && Number.isInteger(e.code) && e.code >= 1000 && e.code <= 4999 ? e.code : 1006;
474
+ const why = e && e.reason ? `: ${e.reason}` : '';
475
+ // 3500-3999 and 4500-4999 are terminal: a long wait, then a new client.
476
+ const after = retryAfterMs(code);
477
+ end(`WebSocket closed (${code})${why}`, code, after, after > 0);
478
+ });
479
+ // After the handshake a close event follows an error. A refused
480
+ // handshake may end with the error alone (Node 22's WebSocket fires no
481
+ // close then), so an error before the open ends the socket here, as a
482
+ // close would have, rather than at the handshake timeout.
483
+ socket.onerror = guard(() => {
484
+ if (ws === socket && handshake !== null) end('WebSocket handshake failed (1006)', 1006, 0, false);
485
+ });
486
+ }
487
+
488
+ retry = setTimeout(start, 0);
489
+ return {
490
+ close() {
491
+ if (closed) return;
492
+ closed = true;
493
+ clearTimeout(retry);
494
+ retry = null;
495
+ detach();
496
+ release();
497
+ }
498
+ };
499
+ }
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "kicksdk-js",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Kick (kick.com) without the I/O: requests, parsers, the realtime chat protocol and webhook verification, from one Rust core",
5
+ "license": "MIT OR Apache-2.0",
6
+ "repository": { "type": "git", "url": "git+https://github.com/AnisAbdellatif/kicksdk.git", "directory": "bindings/js" },
7
+ "keywords": ["kick", "kick.com", "chat", "streaming", "centrifugo", "webassembly"],
8
+ "type": "module",
9
+ "main": "index.js",
10
+ "types": "index.d.ts",
11
+ "exports": {
12
+ ".": { "types": "./index.d.ts", "default": "./index.js" },
13
+ "./kicksdk_bg.wasm": "./pkg/kicksdk_bg.wasm"
14
+ },
15
+ "files": ["index.js", "index.d.ts", "pkg/", "README.md", "LICENSE-MIT", "LICENSE-APACHE"],
16
+ "engines": { "node": ">=22" },
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "../../scripts/build-js.sh",
20
+ "prepack": "node -e \"for (const f of ['LICENSE-MIT', 'LICENSE-APACHE']) require('fs').copyFileSync('../../' + f, f)\"",
21
+ "postpack": "node -e \"for (const f of ['LICENSE-MIT', 'LICENSE-APACHE']) require('fs').rmSync(f, { force: true })\"",
22
+ "test": "node --test test/*.test.js",
23
+ "typecheck": "tsc -p test/types/tsconfig.json"
24
+ }
25
+ }