Files
rudder-js-sdk/README.md
T
edmand46 1491b5e357 Server-side scenario execution: thin effects client replaces local engine
- engine/ (DagWalker, sessions, IndexedDbPlanStore) deleted; server owns the graph
- trigger/callback/counter carry PendingEffect; GET /sdk/v1/scenarios/pending polled
  (30s jittered heartbeat paused on hidden tab + wait-deadline timers)
- client.effects public API unchanged (done/buy/dismiss/end/claim/battlepass/quest)
- planStateStore removed; loginEvent kept; reconcile() makes server the source of truth
- generated models regenerated; skills/README/CHANGELOG updated
2026-09-04 14:08:48 +03:00

7.4 KiB

@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):

npm install @rudder/js-sdk

Quickstart

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
Auth client.auth.loginWithDevice(), client.auth.loginWithCustom({ customData }), client.auth.logout(), client.auth.isAuthenticated, client.auth.onAuthStateChange(cb)
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)
Scenario effects client.effects.on*

Type the remote config for client.remoteConfig:

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.

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:

import { QuestMetrics } from '@rudder/js-sdk';

QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"

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.

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:

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:

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:

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

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.