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