# @rudder/sdk Rudder client SDK for web games: device and social auth, player state (profile, wallets, counters), catalog currencies, player storage, and calls to installed modules through generated typed clients. ## Install The package is published to the Gitea registry at hub.rudder.build, not npmjs. Point the `@rudder` scope at it in your project's `.npmrc`: ``` @rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/ ``` Then install as usual (anonymous read access, no token needed): ```bash npm install @rudder/sdk ``` ## Quickstart ```ts import { createClient } from '@rudder/sdk'; const client = createClient({ projectKey: 'your-project-key', // the key selects the environment // baseUrl: 'https://api.rudder.build' (default) // tokenStore: localStorage with an in-memory fallback (default) // appVersion: '1.4.2', sent with platform 'web' on login and refresh }); await client.auth.loginWithDevice(); const profile = client.player.value; const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance; const wins = profile?.counters?.wins ?? 0; await client.storage.save('progress', JSON.stringify({ level: 7 })); client.dispose(); ``` ## Module clients Game features are installed modules. Generate a typed client for the modules installed in an environment with the `rudder` CLI and commit the file: ```bash export RUDDER_URL=https://api.rudder.build RUDDER_TOKEN=... RUDDER_PROJECT=... rudder client generate --lang ts --out src/rudder.modules.ts # --environment staging (default) | prod ``` ```ts import { createClient, RudderModuleError } from '@rudder/sdk'; import { modules, type LeaderboardsSubmitError } from './rudder.modules'; const client = createClient({ projectKey }); const m = modules(client); await m.leaderboards.submit({ slug: 'weekly', score: 120 }); const top = await m.leaderboards.top({ slug: 'weekly', limit: 10 }); try { await m.leaderboards.submit({ slug: 'weekly', score: 120 }); } catch (error) { if (error instanceof RudderModuleError) { const code = error.code as LeaderboardsSubmitError; console.warn(code, error.message, error.requestId); } } ``` Only `trigger: client` functions are generated. Each generated function calls `client.modules.call(module, fn, args, { moduleVersion })`, which you can also call directly: - `args` is always sent (`{}` when omitted); the promise resolves to `result`. - Every call gets one `Idempotency-Key`, reused on its retries, so a retried call runs at most once on the server. - Retries: up to 2, with jittered backoff, on network errors, 5xx and 409 `conflict`. Other 4xx responses are never retried. - `moduleVersion` is sent as `X-Rudder-Module-Version: @`. The backend logs a warning on a major mismatch and still runs the call. - A 422 rejects with `RudderModuleError` (`code`, `message`, `requestId`). ## Surface | Area | Access | |---|---| | Auth | `client.auth.loginWithDevice()`, `loginWithGoogle({ idToken })`, `loginWithApple({ idToken })`, `loginWithCustom({ customData })`, `linkWithGoogle(idToken)`, `linkWithApple(idToken)`, `linkWithCustom({ customData })`, `unlinkIdentity(provider)`, `logout()`, `isAuthenticated`, `onAuthStateChange(cb)` | | Player | `client.player` state: `player`, `identities`, `wallets`, `counters` | | Catalog currencies | `client.catalog` state (`Map`) | | Storage | `client.storage` state + `.save(type, data)` / `.delete(type)` | | Modules | `client.modules.call(module, fn, args?, { moduleVersion? })`, usually through a generated client | `counters` holds the values of the project's client-visible number counters by slug; currency balances are in `wallets`. ## Error handling All SDK errors extend `RudderError` and carry an optional machine-readable `code`: ```ts import { RudderError, RudderNetworkError, RudderHttpError, RudderAuthError, RudderModuleError, } from '@rudder/sdk'; try { await client.player.reload(); } catch (error) { if (error instanceof RudderAuthError) { // 401: session expired and the refresh failed; the client already // cleared tokens and emitted 'signed-out' via onAuthStateChange. showLogin(); } else if (error instanceof RudderHttpError) { console.error(error.status, error.code, error.body); } else if (error instanceof RudderNetworkError) { // fetch failed or timed out; the underlying error is in error.cause console.error('offline?', error.cause); } } ``` `RudderModuleError` extends `RudderHttpError` (`status` 422). Its type parameter narrows `code` to a generated union such as `LeaderboardsSubmitError`. A module trap is a 500 and a timeout a 504. A missing `projectKey` throws `RudderError` with `code: 'sdk/invalid-options'` from `createClient`. Track the session lifecycle without polling the token store: ```ts client.auth.onAuthStateChange((state) => { // fires immediately with the current state, then on every change setSignedIn(state === 'signed-in'); }); ``` ## Data liveness model State objects (`player`, `catalog`, `storage`) are warmed at login (`player` and `catalog`) or on first use, then kept fresh by a revision poll: the client asks the backend for entity revisions every **30 s (±20% jitter)** and reloads only the entities whose revision grew. Polling **pauses while the tab is hidden** and resumes on `visibilitychange`. Storage writes invalidate the affected domain immediately. Expect data to be eventually consistent within one poll interval; call `reload()` when you need a guarantee. ## React recipe `SyncedState.onChange` fires immediately with the current snapshot, which maps straight onto `useSyncExternalStore`: ```tsx import { useSyncExternalStore } from 'react'; function useSynced(state: { onChange(cb: () => void): () => void; value: T | undefined }) { return useSyncExternalStore( (onStoreChange) => state.onChange(() => onStoreChange()), () => state.value, ); } function Wallet() { const profile = useSynced(client.player); return
{profile?.wallets?.map((w) => `${w.currency}: ${w.balance}`).join(', ')}
; } ``` ## Development ```bash npm run typecheck # tsc over src and tests npm test # unit + local e2e (jsdom) npm run build # tsup → dist (ESM + CJS + d.ts) npm run test:e2e-prod # drives a seeded project on the live backend ``` Generated wire types (`src/generated`) come from `liveops-backend/apigen`; run `npm run generate` to refresh them. Do not edit them by hand. `test/fixtures/rudder.modules.ts` is CLI output for `test/fixtures/leaderboards.manifest.json`; `npm run typecheck` checks that it accepts `RudderClient`. Refresh it after generator changes: ```bash rudder client generate --lang ts --manifests --out test/fixtures/rudder.modules.ts ```