kicksdk

How kicksdk is built, and why

No I/O in the core

The core takes bytes in and gives requests, frames and typed values out. It never connects, sends, sleeps or reads a clock. Every call that depends on time takes now in milliseconds.

The cost is a driver per host. It is small, and written once per language: connectChat in bindings/js/index.js is about 330 lines.

Requests and parsers

A builder returns a Request (method, URL, headers, body). A parser takes (status, body). Errors keep the status, so a caller can tell "Kick said 429" from "Kick changed the shape".

Every builder takes an Endpoints, so any part of Kick can be pointed elsewhere: KickTracker's simulator, BetterChat's relay, a proxy.

Builders refuse (Error::Invalid) what would make the request a different one: a path segment that is empty, . or .. (a URL reads them, and %2e%2e, as a move up the path, so encoding does not help) and a token with a control character (a CR/LF would start a header of its own). The builders that can refuse return Result<Request, Error>.

Secrets never reach a log by accident: Debug hides the authorization header and a form's client_secret in a Request, the token in a session's connect and refresh frames, and a website channel's signed playback_url. Serialize - what a host sends or a binding returns - keeps them, since they are needed there. The realtime session drops playback_url from event data altogether: nothing read from the feed needs it.

Lenient parsing, strict types

Kick sends numbers as strings ("duration": "1200", followers_count either way), leaves fields out, and wraps event data as a JSON string inside the JSON. Parsing goes through lenient.rs, which reads a field or gives None. It never fails the whole value over one odd field. The result is still typed, so callers never touch raw JSON for anything kicksdk knows.

Unknown chat events come out as KickEvent::Unknown { name }, not as errors. New Kick events must not break clients that do not care about them. Public enums and output structs are #[non_exhaustive] for the same reason in the other direction: when kicksdk learns a new event or field, adding it is not a breaking change.

Units are not invented

Where Kick's unit is not settled - a pin's duration is the open case, see KICK.md §8 - the number is passed through as Kick sent it and the doc comment says what is known. A guessed conversion would be wrong for somebody.

The realtime session

realtime::Session is a state machine for one connection: open, receive, tick, refresh, closed in, a list of Actions out (send, close, refresh the token, connected, subscribed, an event). It keeps:

What Kick sends is bounded before use (a ping of 1 s to 10 min, a token lifetime of 1 s to a day) and time sums saturate, so neither Kick nor a host's now can overflow them.

Reconnecting is the host's, timed by the core. Action::Close says how long at least to wait (retry_after_ms: 0 for an ordinary drop, 5 minutes for a terminal close, from Centrifugo's code ranges: 3500-3999 and 4500-4999) and whether it was terminal (terminal: reconnect as a new client id), and realtime::retry_after_ms answers the same for a close code the host's socket reports. Nothing is terminal for good: a refused guest token or a connection limit usually passes, so a driver never stops by itself (KICK.md §6.1). Backoff::next_delay_for(retry_after_ms, random) gives the delay: with 0, the ordinary 1 s doubling to 30 s; with more, a terminal stage that doubles that wait on each terminal close in a row, to six times it (5, 10, 20, 30 minutes). Both are spread by a random number the host draws, since the core has no randomness of its own, and both start over at reset, which the host calls once the chat feed is subscribed. The escalation lives in Backoff, not in Session, because a socket can close with a terminal code without the session having seen anything, and a Backoff is what every driver already keeps across connections.

Webhooks

webhooks::verify takes a PublicKey read once, the headers, the raw body as bytes (a body is verified byte for byte, UTF-8 or not) and a Freshness: the timestamp must be within 5 minutes of now unless the caller skips the check explicitly, to re-check stored deliveries. A valid signature only means Kick sent that body to some app, since every app shares the key; KICK.md §4.3 says what else a receiver should do. webhooks::parse refuses a body that is not the shape of the type header's type, because that header is not signed.

RSA is the rsa crate's, pure Rust so it builds for WebAssembly. Its advisory RUSTSEC-2023-0071 is a timing side channel in private-key operations; kicksdk only verifies, with a public key, so it is not affected (deny.toml and .cargo/audit.toml record the exception). The alternatives (ring, aws-lc-rs) bring C and assembly that do not build for wasm32-unknown-unknown. On that target rsa reaches getrandom 0.2, which the core's Cargo.toml sets to its js backend; verifying never draws a random number.

Bindings

Two ways in:

Per language:

Precompiled, and from source

Users should not need Rust, so every native package ships binaries, and every one can still build without them:

Package Precompiled Checked by Falls back to
npm the .wasm in the package the registry scripts/build-js.sh
PyPI abi3 wheels pip, against the registry the sdist, which carries the crates
pub kicksdk_ffi-v<version>-<triple>.<ext> on the GitHub release, fetched by the build hook hook/checksums.json, written at release cargo build of native/
hex rustler_precompiled tarballs on the GitHub release checksum-Elixir.Kicksdk.Native.exs, written at release Rustler, on native/

native/ is a copy of the Cargo workspace trimmed to the core and the one crate the package needs (scripts/vendor-native.sh), with the repository's Cargo.lock, so a source build is the build CI tested. Neither native/ nor the checksum files are committed. Without them, a checkout of the repository builds from the workspace itself. A binary whose checksum does not match is refused outright: falling back to a source build there would hide a tampered release.

The Dart hook falls back to a source build on any network or TLS failure, and for targets with no precompiled library; only a checksum mismatch fails the build.

Fixtures are the contract

fixtures/ holds Kick's real answers and frames, anonymized. The Rust, JS, Python, Dart and Elixir suites all read them. A Kick change goes like this: record it, see every suite fail, fix the core, and every binding passes again. Recordings never hold a token, a real name, real text or a signed URL; see CONTRIBUTING.md.