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/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +125 -1
- package/index.d.ts +129 -11
- package/index.js +422 -98
- package/package.json +6 -3
- package/pkg/kicksdk.d.ts +735 -97
- package/pkg/kicksdk.js +2390 -580
- package/pkg/kicksdk_bg.wasm +0 -0
- package/pkg/kicksdk_bg.wasm.d.ts +107 -57
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 `
|
|
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
|
-
/**
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
if (
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|