kicksdk-js 0.2.0 → 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/README.md CHANGED
@@ -36,7 +36,9 @@ limit: `terminal: true`) it never gives up, but waits long - 5, 10, 20, then
36
36
  comes back as a new guest client (a new client id, so a new token). The chat
37
37
  feed subscribing again ends the streak. `docs/KICK.md` §6.1 has the rules.
38
38
  A callback that throws is reported to `onError` (or `console.error`) without
39
- disturbing the connection; none fires after `close()`.
39
+ disturbing the connection; none fires after `close()`, and what throws after
40
+ it goes to `console.error`. A panic in the WebAssembly is reported too, and
41
+ ends that connection; the next one starts on a new session.
40
42
 
41
43
  A mistake in the options throws from `connectChat` at once: a bad id or
42
44
  endpoint, a `timeoutMs` that is not a number of ms above 0 and at most
@@ -59,12 +61,72 @@ relay through a server.
59
61
  Everything else - `channelRequest` / `parseChannel`, `historyRequest`,
60
62
  `livestreamsRequests`, `verifyWebhook`, `parseEvent`, `parseContent`, … -
61
63
  returns plain request objects for your `fetch` and parses what comes back.
62
- Parsers throw an `Error` with a `status` when Kick answered with an error.
64
+ Parsers throw an `Error` with a `status` when Kick answered with an error,
65
+ and an `excerpt`: the start of Kick's answer, which often says why
66
+ (`CHANNEL_IS_NOT_LIVE`). The message ends with it too.
63
67
  Every number must be a JS number - a numeric string or a boolean throws a
64
68
  `TypeError` rather than being converted - and ids, statuses, times and
65
69
  counts whole ones in range (ids at most `Number.MAX_SAFE_INTEGER`), or a
66
70
  `RangeError` is thrown rather than the number being rounded into another
67
- id.
71
+ id. The same holds for results: one that would carry an integer above
72
+ `Number.MAX_SAFE_INTEGER` (an id Kick sent that large) throws rather than
73
+ coming back rounded.
74
+
75
+ **User tokens.** To act as a Kick user (send chat, moderate, edit their
76
+ channel, manage rewards), get a user token with OAuth's authorization code
77
+ flow and PKCE. The core draws no random numbers: `newPkce()` and `newState()`
78
+ take them from `crypto.getRandomValues`.
79
+
80
+ ```js
81
+ const pkce = kick.newPkce(); // { verifier, challenge }
82
+ const state = kick.newState();
83
+ // keep pkce.verifier and state (a session) until the user comes back
84
+ location = kick.oauthAuthorizeUrl(clientId, redirectUri, ['user:read', 'chat:write'], state, pkce.verifier);
85
+
86
+ // on the redirect: check ?state= is yours, then
87
+ const token = kick.oauthParseUserToken(...await call(
88
+ kick.oauthCodeRequest(clientId, clientSecret, redirectUri, code, pkce.verifier)));
89
+ // later: oauthRefreshRequest(clientId, clientSecret, token.refresh_token) -> a new pair
90
+ ```
91
+ `oauthRevokeRequest(token, 'access_token' | 'refresh_token')` /
92
+ `oauthParseRevoke`, `oauthIntrospectRequest(token)` / `oauthParseIntrospect`
93
+ (`{active, client_id, exp, scopes, token_type}`), and `oauthScopes()`, the 13
94
+ scopes Kick documents, complete the set; `oauthPkce(bytes)` and
95
+ `oauthPkceFromVerifier(verifier)` make a pair from your own 32 to 96 random
96
+ bytes or a kept verifier. **The verifier, the client secret and both tokens
97
+ are secrets: never log them**, nor a request carrying them
98
+ (`oauthRevokeRequest` puts the token in its URL).
99
+
100
+ With the token (`call` being your `fetch` that returns `[status, text]`):
101
+
102
+ ```js
103
+ const sent = kick.chatParseSend(...await call(kick.chatSendRequest(token, { user: broadcasterUserId }, 'hi')));
104
+ kick.parseDone(...await call(kick.moderationTimeoutRequest(token, broadcasterUserId, userId, 10, 'spam')));
105
+ ```
106
+ - chat: `chatSendRequest(token, {user} | 'bot', content, replyToMessageId?)` /
107
+ `chatParseSend`, `chatDeleteRequest(token, messageId)`;
108
+ - moderation: `moderationBanRequest(token, broadcasterUserId, userId, reason?)`,
109
+ `moderationTimeoutRequest(…, minutes, reason?)`, `moderationUnbanRequest`;
110
+ - the token's channel: `channelsUpdateRequest(token, categoryId?, customTags?, streamTitle?)`;
111
+ - rewards: `rewardsListRequest` / `rewardsParseRewards`,
112
+ `rewardsCreateRequest(token, fields)` and `rewardsUpdateRequest(token, id, fields)` /
113
+ `rewardsParseReward` (`fields` as Kick names them: `{title, cost, is_enabled, …}`),
114
+ `rewardsDeleteRequest`, `rewardsRedemptionsRequest(token, rewardId?, status?, cursor?)`
115
+ and `rewardsRedemptionsByIdsRequest(token, ids, cursor?)` / `rewardsParseRedemptions`,
116
+ `rewardsAcceptRequest` and `rewardsRejectRequest(token, ids)` / `rewardsParseFailures`;
117
+ - ads: `adsAdBreakRequest(token, crypto.randomUUID(), seconds)` / `adsParseAdBreak`,
118
+ `adsStatusRequest` / `adsParseStatus`, `adsEnrollRequest`;
119
+ - drops (an app token): `dropsClaimsRequest(token, {campaign_id, user_id, limit, cursor, …})` /
120
+ `dropsParseClaims`, `dropsUpdateClaimsRequest(token, [{claim_id, external_status}])`;
121
+ - reads, an app or a user token: `usersRequest(token, userIds?)` / `parseUsers`,
122
+ `categoriesRequest(token, names?, tags?, ids?, limit?, cursor?)` / `parseCategories`,
123
+ `livestreamStatsRequest` / `parseLivestreamStats`, `kicksLeaderboardRequest(token, top?)` /
124
+ `parseKicksLeaderboard`.
125
+
126
+ Calls whose answer carries nothing are read with `parseDone(status, body)`,
127
+ which throws with the `status` unless it is a 2xx. Option objects (`fields`,
128
+ the drops filter, the claim updates, `{user}`) refuse a key they do not know.
129
+ `index.d.ts` types every result (`UserToken`, `Reward`, `Page<Category>`, …).
68
130
 
69
131
  **Webhooks.** Read Kick's key once and check each delivery against the raw
70
132
  body (text or bytes), its headers (pairs, a fetch `Headers`, or an object),
@@ -83,3 +145,17 @@ the key): deduplicate on `headers.message_id`. See `docs/KICK.md` §4.3.
83
145
 
84
146
  See the repository's `docs/KICK.md` for everything known about Kick, with
85
147
  sources. MIT OR Apache-2.0. Not affiliated with Kick.
148
+
149
+ ## Live API coverage
150
+
151
+ The shared core was checked against Kick on 2026-11-15: OAuth code exchange
152
+ with PKCE, refresh/revocation/introspection, app and user reads, chat
153
+ send/reply/delete, timeout/ban/unban and reward create/update/delete.
154
+ Bot send returned 404; channel editing was skipped because no original title
155
+ was available to restore. Ad breaks, enrollment, drops and redemption
156
+ accept/reject remain documentation-based. See the repository `docs/KICK.md`
157
+ and `fixtures/{public_api,id}/README.md` for evidence and limits.
158
+
159
+ Check granted OAuth scopes before protected calls: Kick can grant fewer than
160
+ requested. Never log tokens, client secrets, authorization codes, PKCE
161
+ verifiers or requests carrying them.
package/index.d.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  export * from './pkg/kicksdk.js';
2
- import type { Endpoints } from './pkg/kicksdk.js';
2
+ import type { Endpoints, Pkce } from './pkg/kicksdk.js';
3
3
 
4
4
  /** The Origin Kick's realtime server accepts. */
5
5
  export const REQUIRED_ORIGIN: 'https://kick.com';
6
6
 
7
+ /**
8
+ * A new PKCE pair from 32 bytes of `crypto.getRandomValues`, for
9
+ * `oauthAuthorizeUrl` and `oauthCodeRequest`. The verifier is a secret: keep
10
+ * it until the user comes back, never log it.
11
+ */
12
+ export function newPkce(): Pkce;
13
+
14
+ /** A new OAuth `state`: 32 bytes of `crypto.getRandomValues`, base64url without padding. */
15
+ export function newState(): string;
16
+
7
17
  /**
8
18
  * Loads the WebAssembly: a URL, a Response (or a promise of one), the .wasm
9
19
  * bytes or a compiled module. Safe to call more than once; after a failure,
@@ -100,7 +110,8 @@ export interface ChatOptions {
100
110
  onDrop?: ((reason: string, drop: ChatDrop) => void) | undefined;
101
111
  /**
102
112
  * A callback above threw, or `now` gave a reading it cannot use. Default:
103
- * console.error. The connection carries on either way.
113
+ * console.error. The connection carries on either way. Not called after
114
+ * `close()`: what throws then goes to console.error.
104
115
  */
105
116
  onError?: ((error: unknown) => void) | undefined;
106
117
  /** The fetch to make Kick's requests with. Default: the global fetch. */
package/index.js CHANGED
@@ -20,13 +20,35 @@ import initWasm, {
20
20
  parseConnection,
21
21
  tokenRequest,
22
22
  parseToken,
23
- retryAfterMs
23
+ retryAfterMs,
24
+ oauthPkce
24
25
  } from './pkg/kicksdk.js';
25
26
 
26
27
  export * from './pkg/kicksdk.js';
27
28
 
28
29
  export const REQUIRED_ORIGIN = 'https://kick.com';
29
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
+
30
52
  let ready = null;
31
53
 
32
54
  /**
@@ -149,7 +171,9 @@ function socketFactory(o) {
149
171
  *
150
172
  * Callbacks are isolated: one that throws is reported to `onError` (or
151
173
  * console.error) and the connection carries on. None is called after
152
- * close(), which may be called from inside one.
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.
153
177
  *
154
178
  * @param {object} o ChatOptions, in index.d.ts
155
179
  * @returns {{ close(): void }}
@@ -192,7 +216,8 @@ export function connectChat(o) {
192
216
  let retry = null;
193
217
 
194
218
  function report(error) {
195
- if (o.onError) {
219
+ // After close() no callback runs, onError included.
220
+ if (o.onError && !closed) {
196
221
  try {
197
222
  o.onError(error);
198
223
  return;
@@ -371,9 +396,22 @@ export function connectChat(o) {
371
396
  }
372
397
  }
373
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
+
374
412
  function release() {
375
- if (session) session.free();
376
- if (backoff) backoff.free();
413
+ free(session);
414
+ free(backoff);
377
415
  session = backoff = null;
378
416
  }
379
417
 
@@ -397,7 +435,14 @@ export function connectChat(o) {
397
435
  function end(reason, code, retryAfterMs, terminal) {
398
436
  detach();
399
437
  if (closed) return;
400
- session.closed();
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
+ }
401
446
  state();
402
447
  drop(reason, code, retryAfterMs, terminal);
403
448
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kicksdk-js",
3
- "version": "0.2.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" },