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/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +84 -2
- package/index.d.ts +141 -0
- package/index.js +499 -0
- package/package.json +23 -4
- package/pkg/kicksdk.d.ts +474 -0
- package/pkg/kicksdk.js +2036 -0
- package/pkg/kicksdk_bg.wasm +0 -0
- package/pkg/kicksdk_bg.wasm.d.ts +69 -0
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|