kicksdk 0.2.0
kicksdk: ^0.2.0 copied to clipboard
Kick (kick.com) without the I/O - requests, response parsers, the realtime chat protocol and webhook verification - from one Rust core, through dart:ffi.
Changelog #
0.2.0 #
Fixes from two security and robustness audits of the core, the bindings and
the release. Breaking (0.x): Rust signatures, some dispatch arguments
and Action shapes, and several binding APIs change; see each section, and
"For binding authors". The chat drivers never stop on their own any more:
after a terminal close they reconnect as a new client after a long wait.
0.1.1's x86_64 Linux NIF and x86_64/aarch64 Linux C libraries needed glibc
2.34; this release's need 2.17 (2.18 for the aarch64 C library).
Webhooks #
- Breaking:
webhooks::verifychecks freshness and takes a parsed key and raw bytes:verify(key: &PublicKey, headers: &Headers, body: impl AsRef<[u8]>, freshness: Freshness) -> Result<(), WebhookError>.PublicKey::from_pem(pem: &str) -> Result<PublicKey, WebhookError>(alsoFromStr): read Kick's key once, not per delivery.Freshness::at(now_unix_secs: u64)(toleranceDEFAULT_TOLERANCE_SECS= 300),Freshness::Within { now, tolerance_secs }, orFreshness::Skipto re-check stored deliveries on purpose.- New
verify_pem(pem: &str, headers, body, freshness): the old PEM-per-call form, with freshness. - New errors:
WebhookError::Stale { offset_secs: i64 }(timestamp minus now),WebhookError::MalformedHeader(&'static str).
- Breaking:
Headers::from_pairs(andverify, for hand-built headers) require the message id to be a ULID and the timestamp RFC 3339, so no header can move theid.timestamp.bodyboundary of the signed text. - Breaking:
webhooks::parse(event_type: &str, body: impl AsRef<[u8]>) -> Result<WebhookEvent, kicksdk::Error>(wasserde_json::Error). It refuses (Error::Shape) a body that lacks the claimed type's own field (is_live,metadatawith a title/language/category,follower,subscriber,giftees,gift): the type header is unsigned, so a signed follow can no longer be passed off as, say, a Kicks gift. No known type's body has another's own field, so every re-typing between known types is refused (tested pairwise and against the recordings). Other fields, including ones Kick adds later, are ignored. Unknown types still come out asOther, unchecked;.newand.renewalsubscriptions share one shape. - Docs (
webhooksmodule, KICK.md §4.3): a valid signature means "Kick sent this to some app"; deduplicate on the message id and check freshness. A Kick retry (none seen yet) would likely carry the original timestamp and come backStale; a receiver that prefers late retries to losing them can widen the tolerance and must remember message ids as long. rsastays (verify only); RUSTSEC-2023-0071 does not apply and is recorded indeny.tomland.cargo/audit.toml.
Requests #
- Breaking: builders that put user input in a URL path refuse an empty
segment,
.and..(Error::Invalid), and builders that send a token refuse one with a control character (CR/LF header injection). Now returningResult<Request, Error>:website::channel_request,channel_user_request,me_request,ban_request,unban_request,delete_message_request,unpin_request;public_api::subscriptions_request. NowResult<Vec<Request>, Error>:public_api::livestreams_requests(no longer drops a failing batch silently) andpublic_api::unsubscribe_requests. - Breaking:
content::emote_url(id) -> Result<String, Error>: digits only. - Breaking:
#[non_exhaustive]onEndpoints(build withEndpoints::default().with_api(..),.with_id,.with_website,.with_realtime, or assign fields),Request(Request::new(method, url)is now public),Method,public_api::Sort,content::Part,realtime::SessionOptions(useSessionOptions::new, then set fields). Request'sDebughides theauthorizationheader and a form'sclient_secret;WebsiteChannel'sDebughidesplayback_url;public_api::AppToken'sDebughidesaccess_token.Serializeoutput is unchanged.- Breaking (Rust only; JSON unchanged, the ids are transparent): typed
ids where outputs had a bare
u64:ChatMessage::chatroom_id(ChatroomId),Sender::id,ReplyTo::sender_id,UserRef::id,website::ChannelUser::id,webhooks::Person::user_id(UserId),Hosted::channel_id,public_api::Livestream::channel_id(ChannelId), andwebsite::ReplyTarget::sender_id(UserId) on the way in. - Ids above
i64::MAXare read exactly instead of being clamped toi64::MAX; fractional or inexact float ids areNone, never truncated. content::parseis linear on any input (it was quadratic on[emote:1:repeated without]). An emote name ends at the first[or].
Realtime #
- Breaking:
Action::Close { code: i64, reason: String, terminal: bool, retry_after_ms: u64 }(was{ reason }; the variant is#[non_exhaustive]). Serialized, in this key order:{"action":"close","code":3501,"reason":"Kick disconnected: 3501 banned","terminal":true,"retry_after_ms":300000}.codeis Kick's disconnect code, Kick's error code for a refused connect/refresh, or 0 when the session gave up (silence).terminalis true for a disconnect code in 3500-3999 or 4500-4999, a disconnect outside 3000-4999 whose push says"reconnect": false, and a refused connect or refresh other than 109,temporaryor a transport error (< 100);retry_after_msis thenrealtime::TERMINAL_RETRY_MS(300 000), else 0. - Product decision: a terminal close is never the end. A refused guest
token, a connection limit or a ban on an IP usually passes for a chat
reader, so drivers no longer stop on one: they wait at least
retry_after_ms(5 minutes, escalating, seeBackoff) and reconnect with a new client id (so a new guest token). They stop only when the host closes them. KICK.md §6.1 has the table every driver follows. - New
realtime::retry_after_ms(code: u16) -> u64andrealtime::TERMINAL_RETRY_MS: u64 = 300_000: for the close code a host's socket reports, 300 000 for 3500-3999 and 4500-4999 (terminal), 0 otherwise. (An earlier unreleasedshould_reconnect(code) -> boolis gone.) - New
Backoff::next_delay_for(&mut self, retry_after_ms: u64, random: f64) -> u64: with 0, exactlynext_delay_jittered(random); with more, a terminal stage:wait = min(retry_after_ms * 2^min(n, 3), retry_after_ms * 6)wherenis the number of terminal closes since the lastreset(), returned aswait + floor(floor(wait / 4) * r)(r=randomclamped to [0, 1], NaN as 0; saturating). With 300 000 andr= 0: 300 000, 600 000, 1 200 000, 1 800 000, 1 800 000 … The ordinary stage is left as it was.reset()now also clears the terminal count; call it when the chat feed subscribes.Backoff's serde form gainsterminal_closes(#[serde(default)], so older state still reads). - Unsubscribe pushes with codes 2500-2999 are resubscribed by the session:
it emits
Unsubscribedthen aSendwith the subscribe;SubscribedorSubscribeFailedfollows. - A connect reply without
pingmeans the server does not ping: no silence timeout then (deadline()can beNonewhile connected). Kick's ping is bounded to 1-600 s and ttl to 1 s - 1 day; time sums saturate. closed()also reports feeds whose subscribe had no answer yet, andopen()on a session that was notclosed()returns thoseUnsubscribedactions before the connectSend.- A feed listed twice in
SessionOptions::channelsis subscribed once. Action::Event'sdatanever contains aplayback_url(removed at any depth, e.g. fromChatMoveToSupportedChannelEvent). Only data that had one is re-encoded; any otherdatastring (a chat message that merely mentionsplayback_url, say) is handed on byte for byte as Kick sent it.Action'sDebughides the token in connect/refresh frames.Backoff::newclamps a first wait of 0 to 1 ms and a max below the first up to it. NewBackoff::next_delay_jittered(random: f64) -> u64: the delay spread over [half, all] by a host-drawn number in [0, 1):half + floor((delay - half) * r), at least 1, wherehalf = floor(delay / 2).
Build #
kicksdkbuilds forwasm32-unknown-unknownwith default features (getrandom'sjsbackend, for rsa's dependencies, on that target only).- Every feature combination builds and tests;
dispatch::FUNCTIONSlistswebhooks.*only with thewebhooksfeature. - serde_json's
preserve_orderis no longer forced on every user through the workspace: thedispatchfeature and the WebAssembly crate enable it, so binding output keeps its key order.
JavaScript (kicksdk-js) #
- New:
retryAfterMs(code)(replaces the unreleasedshouldReconnect),Backoff.nextDelayJittered(random),Backoff.nextDelayFor(retryAfterMs, random),new WebhookPublicKey(pem).verify(...). Breaking:verifyWebhook(pem, headers, body, {now, toleranceSecs} | {skipFreshness: true})checks freshness (default: this machine's clock, 5 minutes;nowin seconds); bodies may be a string,Uint8ArrayorArrayBuffer;parseWebhooktakes bytes too. - Breaking: every numeric argument (ids,
now, Backoff bounds,minutes,limit, event versions, parser statuses 0-65535,random,retryAfterMs) must be a JS number, whole and non-negative where it is a count (TypeErrorfor a string, boolean or BigInt,RangeErrorout of range);parseContent'scombinemust be a boolean; an unknown endpoint key orsort, or a malformedreplyTo, throws instead of being replaced or dropped. connectChat:- reconnects as soon as the session gives up on a quiet socket, without waiting for the old socket to close (in Node it never did);
- never stops on its own: after a terminal close (a session
closeaction withterminal, or a socket close coderetryAfterMsgives a wait for) it reconnects as a new guest client afterBackoff.nextDelayFor's long wait (KICK.md §6.1). Breaking:onDrop(reason, {code, terminal, waitMs}),waitMsbeing the wait actually scheduled; - new options:
onError(error)(defaultconsole.error) for exceptions thrown by your callbacks, which no longer break the driver;timeoutMs(default 10 s, at most 2^31-1 ms) for each request and the handshake;WebSocketfor runtimes without a global one (Node 20, or thewspackage); without any,connectChatthrows aTypeError; - function options are checked up front (
TypeError); anowreading that is not a usable number is reported toonErroronce andperformance.now()carries on from the last good one; - a feed listed twice is subscribed and reported once; with
channels: []the backoff resets when Kick accepts the connection; - every optional option may be given as
undefined; onDropalso fires when the socket closes or the handshake is refused, and nothing fires afterclose(), which may be called twice or from inside a callback;- reconnect waits are jittered; the session clock is
performance.now().
init()retries after a failed load instead of returning the same rejection forever; it resolves toundefined.- Types:
endpointsoptional everywhere, user idsreadonly number[],ChatState,ChatDrop,ChatSocketexported; checked by a strict TypeScript consumer in the tests. - The npm package includes
LICENSE-MITandLICENSE-APACHE.
Python (kicksdk-py) #
- New:
kicksdk.realtime.retry_after_ms(code)(replaces the unreleasedshould_reconnect) andrealtime.TERMINAL_RETRY_MS;Backoffwraps the core's, withnext_delay(),next_delay_jittered(random)andnext_delay_for(retry_after_ms, random)(ms);initialandmaximumstay settable.Backoff(0)now waits 1 ms.NativeError.__module__iskicksdk._native, and the extension's stub matches it (stubtest). - Breaking:
webhooks.verify(pem, headers, body, *, now=None, tolerance_secs=None, skip_freshness=False)checks freshness by default (nowdefaults totime.time(); it must be an int or float, finite and not negative, and is floored);bytesbodies are verified as bytes.webhooks.parsetakes bytes too. - Breaking:
kicksdk.aio:import kicksdk.aiowithout the[aio]extra raises anImportErrorthat says to install it (it used to hang the iterator);- the feed never stops on its own: after a terminal close it reconnects
as a new client after
Backoff.next_delay_for's long wait (KICK.md §6.1), whether the close code arrives on a receive or on a send; frommax_refusalsrefusals in a row (default 5, e.g. a 403 handshake; any other outcome resets the count) each refusal waits as long as a terminal close.chat.last_drop(aChatDrop: reason, code, terminal, retry_after_ms, wait, status, error) andchat.retryingshow what it is doing;backoff=takes your ownBackoff; - the iterator raises only unexpected errors (bugs, bad arguments), and
ends when the
async withblock exits;chat.error,chat.done. Leaving the block no longer hangs on Python 3.9-3.11 with frames queued (it could forever, since 0.1.1); - a failed token refresh keeps the connection, and the refresh no longer blocks ping replies, and its errors are always handled; a normal exit closes with 1000, not 1011;
max_queueandoverflow("drop_oldest", counted inchat.dropped, or"backpressure") replace the silent drop at 10,000 events;http=takes your ownhttpx.AsyncClient; reconnect waits are jittered.
- Wheels and the sdist include
LICENSE-MITandLICENSE-APACHE.
Dart (kicksdk) and the C ABI (kicksdk-ffi) #
- C:
#define KICKSDK_ABI_VERSION 1uinkicksdk.h; the header documents thatkicksdk_session_newmay return NULL and one call at a time per session. A panic inside a call no longer prints to stderr, and its envelope carries the panic message. - New:
Realtime.retryAfterMs(code)(replaces the unreleasedshouldReconnect),Backoff.nextDelayJittered,Backoff.nextDelayFor(aDuration; the core's formula, ported). Breaking:Webhooks.verify(pem, headers, Object body, {DateTime? now, Duration? tolerance, bool skipFreshness = false})checks freshness (default: the machine clock); aList<int>body is verified as bytes. connectChat: newtimeout(default 10 s, each request and the handshake),onError,backoff,onDropInfo(aChatDrop: reason, code, terminal, retryIn) andChatConnection.done, which completes only afterclose(): the connection never stops on its own, and after a terminal close it reconnects as a new client afterBackoff.nextDelayFor's long wait (KICK.md §6.1).onDrop(String)is unchanged. A socket the session gives up on is let go at once (no 5 s wait on a half-open one), and every lost socket is closed. Nothing,onErrorincluded, is called back afterclose(); exceptions in your callbacks no longer lose the rest of a batch; a failed token refresh is retried on the same socket; reconnect waits are jittered.- Build hook: the download has a connect and an overall timeout; any
network or TLS failure, or a
binary_base_urlthat is not http(s), falls back to a source build, while a checksum mismatch still fails the build. Targets without a precompiled library (Linux arm, riscv64 and musl, Windows ia32, Android ia32/riscv64) build from source instead of failing. New user definescargo_path,cargo_home,rustup_homefor toolchains outside the default location; Android source builds link for the app's minimum NDK API. A musl host is recognised by the mapped C library's file name, not by "musl" anywhere in its path.
Elixir (kicksdk) #
- Breaking: builders that can refuse their arguments return
{:ok, request} | {:error, reason}:Website.channel_request,channel_user_requestand the acting builders,PublicApi'slivestreams_requests,subscriptions_request,unsubscribe_requests,top_livestreams_request, andContent.emote_url.Kicksdk.callreturns request headers as{k, v}tuples, like the wrappers. - Breaking:
Webhooks.verify/4takes:now(defaultSystem.os_time(:second)),:tolerance_secsand:skip_freshness, and verifies the raw bytes. A delivery that cannot be trusted (header, key, signature, timestamp) is{:error, {:webhook, message}}; a signed one whose body is not the claimed type's shape is{:error, {:shape, what}}({:json, _}if not JSON), fromverify/4andKicksdk.call/2alike. NewWebhooks.public_key/1reads Kick's key once. - Non-UTF-8 input returns
{:error, _}instead of raising (webhook bodies verify byte for byte). Sessiontakes any integernow,System.monotonic_time(:millisecond)included;deadline/1answers on the caller's clock. A poisoned session raises a clearRuntimeError;closed/1recovers it. A call on a session that another process is calling at the same moment raisesRuntimeError("busy") instead of blocking a scheduler until the other call ends.- New:
Realtime.retry_after_ms/1(replaces the unreleasedshould_reconnect?/1),Kicksdk.Realtime.Backoff(withnext_delay_jittered/2andnext_delay_for/3, whose struct carries the terminal-close count interminal_closes;reset/1clears it). - Breaking: the session's
"close"action has"code","reason","terminal"and"retry_after_ms"instead of"reconnect"; a terminal close means "wait longer and come back as a new client", never "stop". - Breaking: endpoints with an unknown key (even with a
nilvalue), a non-string value, or a struct (%URI{}) instead of a map raiseArgumentError; HTTP statuses outside 0..65535 are{:error, {:invalid, _}}. TheKicksdk.endpointstype says what is accepted: atom or string keys,nilvalues for Kick's own. - NIFs whose work grows with the input run on a dirty scheduler above 4 KiB.
config :rustler_precompiled, :force_build, kicksdk: trueworks; changingKICKSDK_BUILD,KICKSDK_BASE_URLorTARGET_*recompiles the module, also after a cross build (whose NIF does not load on the build machine; the check now lives inKicksdk.Native.Build, which always loads). As a dependency, Mix does not look:mix deps.compile kicksdk --force. An unrecognised Unix system architecture or aTARGET_*target without a precompiled NIF falls back to a source build instead of failing; Windows (architecturewin32) gets the precompiled NIF as before.KICKSDK_BASE_URLwithout a path (http://localhost:8080) works: a/is added (rustler_precompiled asked forhttp://localhost:8080kicksdk_nif-…).examples/elixir/chat.exsfollows KICK.md §6.1: ticks, refreshes its token, reconnects afterBackoff.next_delay_for/3of the close'sretry_after_ms(or of the socket's close code), comes back as a new client (new client id, connection request and token) after a terminal close, resets the backoff when the chat feed subscribes, and stops only when told to (Chat.stop/1). Frames queued for a socket that dropped are not sent on the next one.- Integers beyond 64 bits in Kick's JSON still arrive as floats (the core reads JSON without arbitrary precision); documented.
Dispatch #
- Breaking:
endpointsmust be an object whose keys are theEndpointsfields; an unknown key is acallerror instead of being ignored (a misspelt key would have sent that request to kick.com). Anullvalue keeps Kick's URL. - Breaking: optional string and boolean arguments are checked like
numbers: a non-string
sort,language,reply_to.content,bodyorbody_base64, or a non-booleancombineorskip_freshness("true",1), is acallerror instead of being taken for "left out" (which silently sorted by viewers, dropped the language filter, or checked freshness the host meant to skip). Left out ornullstill means the default.
Release #
- The Linux libraries are built with
crossand checked against a glibc floor (2.17, 2.18 for the arm64 C library, 2.27 for riscv64); 0.1.1's x86_64 NIF and x86_64/arm64 C libraries needed glibc 2.34. - Release assets are never replaced once uploaded; NIF tarballs are reproducible; hex and pub checksum the release's own files, and pub requires all 12 libraries.
- Libraries are stripped, macOS ones get
@rpathinstall names, and the Windows MSVC builds link the C runtime statically. - The pub package carries the Rust crates' sources (
native/vendor/, with their checksum lists), so a source build works offline; CI builds the pub and hex packages from their own archives, as users get them. The hex package ships LICENSE and CHANGELOG. - Re-runs run from the tag; a dry run builds and checks everything on pull
requests that touch the release, and weekly. See
docs/releasing.md.
For binding authors #
- Terminal closes reconnect (Realtime above; KICK.md §6.1). What each
driver does, exactly:
- On a session
closeaction{"action":"close","code":i64,"reason":str,"terminal":bool,"retry_after_ms":u64}: close the socket, callsession.closed(), report it (terminalis worth a warning), then, unless the host closed the driver, waitbackoff.next_delay_for(retry_after_ms, random())ms and reconnect. Ifterminal, generate a new client id before the connection request and the token request. Never stop because ofterminal. - On the socket closing by itself with a close code (no
closeaction first):session.closed(), thenwait = backoff.next_delay_for(retry_after_ms(code), random());retry_after_ms(code) > 0means terminal: new client id. The language-level function isrealtime.retry_after_ms {code}(dispatch) -> integer ms; a code outside 0-65535 is acallerror. - On a failure without a close code (handshake refused, network error,
a token or connection request failing):
backoff.next_delay_for(0, random()), i.e.next_delay_jittered. - On
{"action":"subscribed","channel":C}whereCis the chat feed (chat_channels(...)[0]):backoff.reset()(both stages). - Drivers' public surface: replace
reconnectin drop/close callbacks withterminalandretry_after_ms(or the actual wait); remove "stopped" /ChatClosed-style terminal outcomes, or keep them only for a host-requested close. Tests that expect a stop on 3501/4501 must expect a reconnect after the long wait (inject the backoff or the clock: the wait is 300 000 ms or more). Backoffin each binding gainsnext_delay_for(retry_after_ms, random)(JSnextDelayFor, DartnextDelayForreturning aDuration, Elixirnext_delay_for/3->{delay, backoff}) andresetclearing the terminal count. WhereBackoffwraps the core (wasmBackoff, Python'sNativeBackoffinbindings/python/src/lib.rs), add the method there; Dart'sBackoffis pure Dart and must implement the formula above exactly (test vectors:next_delay_for(300000, 0.0)x5 -> 300000, 600000, 1200000, 1800000, 1800000; afterreset, 300000;next_delay_for(300000, 0.5)first -> 337500;next_delay_for(0, 0.0)on a fresh default -> 500). The Elixir NIF keeps aBackoffas{initial_ms, max_ms, next_ms}: add the fourth integer,terminal_closes(the serde field name), to the tuple and toKicksdk.Realtime.Backoff, or the count is lost between calls.- Every binding's driver and example follows this: JS
connectChat, Pythonkicksdk.aio, DartconnectChat,examples/rustandexamples/elixir/chat.exs(Elixir has no driver of its own).
- On a session
dispatch:- New function
realtime.retry_after_ms {code}-> integer ms (0 or 300000). The unreleasedrealtime.should_reconnectis removed; calling it is acallerror ("no function"). webhooks.verify {public_key_pem, headers, body | body_base64, now, tolerance_secs?}or{…, skip_freshness: true}instead ofnow.nowis Unix seconds.body_base64carries a body that is not UTF-8. Missingnowis acallerror.webhooks.parse {event_type, body | body_base64}; a body that is not the type's shape fails with kindshape(was alwaysjson).webhooks.verify: a delivery whose signature verifies but whose body is not the claimed type's shape now fails with kindshape(jsonif the body is not JSON), no longerwebhook.webhookis kept for what makes a delivery untrustworthy (missing/malformed header, bad key, signature mismatch, stale timestamp). Bindings that map kinds (Elixir's{:error, {:webhook, msg}}, Python's and Dart's exceptions) should surfaceshapedistinctly.- Numbers must be exact integers in their range (
1.0is accepted;1.5,-1,1e30, 2^32+ forlimit/version/minutesarecallerrors instead of being rounded or truncated). - Optional strings and booleans must be of their type or null/absent
(see Dispatch): pass
None/nullfor "default", never""-for-bool or a number-for-string. - Builders listed above under Requests can now fail with kind
invalid;content.emote_urltoo. - Session actions:
closeis{"action","code","reason","terminal","retry_after_ms"}, in that key order; see Realtime.
- New function
- Every binding now exposes all of the above; see its section.
0.1.1 #
The first published release. 0.1.0 reached PyPI only (as kicksdk-py); its
other packages never shipped:
- npm: published as
kicksdk-js(npm refuseskicksdkas too close tokick-sdk). - Elixir NIFs and the Android libraries now build in the release workflow.
0.1.0 #
First version, from what KickTracker, BetterChat, KickPlus and Viewmux had each learnt about Kick (docs/KICK.md).
- Core (
kicksdkcrate): official API, website API, realtime (Centrifugo) endpoints and session, chat-feed events, message content, webhooks. - JavaScript (
kicksdk-json npm): the core in WebAssembly, andconnectChat. - Elixir (
kicksdkon hex): the core as a NIF, precompiled (rustler_precompiled). - Python (
kicksdk-pyon PyPI, imported askicksdk): PyO3, abi3 wheels, andkicksdk.aio.connect_chat. - Dart / Flutter (
kicksdkon pub.dev):dart:ffiover a C ABI (kicksdk-ffi), precompiled through a build hook, andconnectChat. kicksdk::dispatch: every function by name, JSON in and out.- Every native package builds from vendored sources when no precompiled binary fits, and refuses one whose checksum does not match.