Files

219 lines
7.4 KiB
Markdown
Raw Permalink Normal View History

2026-08-12 14:02:25 +03:00
# @rudder/js-sdk
LiveOps Web SDK for browser games — device auth, observable player state,
stores, remote config, leaderboards, quests, battle pass, and a scenario runtime
surfaced as typed effects.
## Install
The package is published to a private registry, 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/js-sdk
```
## Quickstart
```ts
import { RudderClient } from '@rudder/js-sdk';
const client = new RudderClient({
baseUrl: 'https://api.rudder.build',
projectKey: 'your-project-key',
// tokenStore is optional: defaults to localStorage with an in-memory
// fallback where localStorage is unavailable (SSR, private mode).
});
await client.auth.loginWithDevice(); // region 'global', language 'en'
// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' });
// Observable domains — read `.data`, subscribe with `onChange`, force `reload()`.
const profile = client.player.data;
const speed = client.remoteConfig.get('player_speed', 200);
client.stores.onChange(({ data: stores }) => renderStores(stores ?? []));
// Scenario-driven UI arrives through effects.
client.effects.onStoreOffer((offer) => showOffer(offer));
client.effects.onScenarioFailed(({ error }) => console.error('scenario failed', error));
// Tear down background work (sync poll, wait timers) on unmount / HMR.
client.dispose();
```
## Surface
| Area | Access |
|---|---|
2026-08-28 15:18:31 +03:00
| Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
2026-08-12 14:02:25 +03:00
| Player / wallets | `client.player` (observable) |
| Inventory | `client.inventory` (observable, catalog-merged) |
| Catalog | `client.catalog` (observable) |
| Stores | `client.stores` (observable) + `client.stores.purchase(slug, offerId)` |
| Remote config | `client.remoteConfig.get(key, default)` (observable, typed) |
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
| Leaderboards | `client.leaderboards.findBySlug(slug)``handle.submit(score)` / `handle.list(limit?)` |
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) |
| Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` |
2026-08-12 14:02:25 +03:00
| Scenario effects | `client.effects.on*` |
Type the remote config for `client.remoteConfig`:
```ts
interface GameConfig extends Record<string, unknown> {
player_speed: number;
feature_x: boolean;
}
const client = new RudderClient<GameConfig>({ /* … */ });
client.remoteConfig.get('player_speed', 200); // number
```
## Quests
`client.quests` covers the player's global quests — list with per-objective
progress, claim, and metric reports. These are distinct from scenario quest
nodes, which advance through the `onQuest` effect's `QuestSession`. Global
quests have no live sync: re-list after a claim or report.
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.id) {
await client.quests.claim(quest.id);
}
}
// Custom metrics advance matching objectives server-side; the call returns
// the ids of quests completed by this report.
const completedIds = await client.quests.reportProgress('kills', 1);
```
Purchase metrics are reported automatically when a purchase goes through
`client.stores`; the `QuestMetrics` helpers name the format so quest configs
and client code agree on it:
```ts
import { QuestMetrics } from '@rudder/js-sdk';
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
2026-08-12 14:02:25 +03:00
## Scenario effects
The scenario runtime is not exposed directly; scenario nodes surface through
`client.effects`:
- `onNotification`, `onStoreOffer`, `onLeaderboard`, `onConfigChanged`
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
- `onScenarioCompleted`, `onScenarioFailed`
The server executes the scenario graph. The SDK is a thin effects client
(trigger, pending poll, callbacks) loaded lazily on first login — a client
that only reads domains never pulls it into the page. `onConfigChanged`
stays on the surface, but scenario `remote_config_override` nodes are
applied server-side and no longer emit that effect.
2026-08-12 14:02:25 +03:00
Errors thrown inside effect handlers are reported via the `onEffectError`
client option (default: `console.error`).
## Error handling
All SDK errors extend `RudderError` and carry an optional machine-readable
`code`:
```ts
import {
RudderError,
RudderNetworkError,
RudderHttpError,
RudderAuthError,
RudderErrorCodes,
} from '@rudder/js-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) {
if (error.code === RudderErrorCodes.runExpired) {
// typed server error code
}
console.error(error.status, error.code, error.body);
} else if (error instanceof RudderNetworkError) {
// fetch failed / timeout — the underlying error is in error.cause
console.error('offline?', error.cause);
}
}
```
Invalid client options (missing `baseUrl` / `projectKey`) throw `RudderError`
with `code: 'sdk/invalid-options'` from the constructor.
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
Observable domains (`player`, `inventory`, `catalog`, `stores`, `remoteConfig`,
`storage`) are warmed once at login and then kept fresh by a revision poll:
the client asks the gateway 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`. Mutations (purchases,
storage writes, scenario callbacks) invalidate the affected domains
immediately, so subscribers see fresh data without waiting for the next poll.
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<T>(state: { onChange(cb: () => void): () => void; data: T | undefined }) {
return useSyncExternalStore(
(onStoreChange) => state.onChange(() => onStoreChange()),
() => state.data,
);
}
function Wallet() {
const profile = useSynced(client.player);
return <div>{profile?.wallets?.map((w) => `${w.currency}: ${w.balance}`).join(', ')}</div>;
}
```
The same pattern works for `client.remoteConfig`, `client.stores`, and
`client.auth.onAuthStateChange`.
## 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 gateway
```
Generated wire types (`src/generated`) come from the gateway's `apigen`; run
`npm run generate` to refresh them. Do not edit them by hand.