From aa9d9800c92d94b33f42f04815a7b9ad5474cae3 Mon Sep 17 00:00:00 2001 From: edmand46 Date: Thu, 17 Sep 2026 15:28:08 +0300 Subject: [PATCH] docs: README, CHANGELOG and skill for @rudder/sdk 0.1.0 with generated module clients --- CHANGELOG.md | 189 +++---------------- README.md | 125 ++++++++---- skills/rudder-web-sdk/SKILL.md | 143 +++++++------- skills/rudder-web-sdk/reference/auth.md | 8 +- skills/rudder-web-sdk/reference/modules.md | 78 ++++++-- skills/rudder-web-sdk/reference/player.md | 13 +- skills/rudder-web-sdk/reference/resources.md | 29 --- skills/rudder-web-sdk/reference/storage.md | 68 ++----- 8 files changed, 270 insertions(+), 383 deletions(-) delete mode 100644 skills/rudder-web-sdk/reference/resources.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6956c83..d85a61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,173 +1,28 @@ # Changelog -## Unreleased +## 0.1.0 -- Removed `client.effects`, the `Effects` / `EffectUnsubscribe` / - `NotificationEffect` / `WaitEffect` / `ScenarioCompletedEffect` / - `ScenarioFailedEffect` types, and the `onEffectError` client option. Nothing - emitted effects since scenarios were removed. -- `client.modules.call` resolves to `CallModuleFunctionClientResponse`, which - carries only `result`. Console output, the kernel call log and `error` are - no longer part of the player response; they stay on the platform dry run. -- Module business errors reject with `RudderHttpError` (`status` 422, body - `{ error, code, requestId }`). `code` is defined by the module and is an - open string. -- `RudderErrorCodes` and the `RudderErrorCode` type are no longer exported - from the package. The generated platform codes are now only `forbidden` and - `row_referenced`; the scenario and quest codes (`early_completion`, - `level_not_reached`, `node_not_active`, `objectives_incomplete`, - `run_expired`, `run_not_active`, `scenario_not_active`, `unknown_run`) are - removed with those domains. Compare `error.code` with string literals. -- Login and token refresh send `platform: 'web'` and, when the new optional - `appVersion` client option is set, `appVersion`. The backend stores them on - the player for segment rules (`player.platform`, `player.appVersion`). +First release as `@rudder/sdk`. Not compatible with `@rudder/js-sdk` 2.x. -## 2.0.0 +- `createClient({ projectKey })` creates the client; `baseUrl` defaults to + `https://api.rudder.build`. +- Kernel surface only: `auth`, `player` (player, identities, wallets, + `counters`), `catalog`, `storage`, `modules`. Revision sync keys are + `profile`, `catalog` and `storage`. +- State objects expose `value` (was `data`), `status`, `error`, `load()`, + `reload()` and `onChange()`. +- `storage.save(type, data)` upserts the single item of a type; + `storage.delete(type)` removes it. +- `modules.call(module, fn, args?, { moduleVersion? })` resolves to + `result` as `T`. It always sends `args` (`{}` when omitted), one + `Idempotency-Key` per call reused on retries, and + `X-Rudder-Module-Version: @` when a version is given. + Retries up to 2 times with jittered backoff on network errors, 5xx and 409 + `conflict`. A 422 rejects with `RudderModuleError` (`code`, `message`, + `requestId`). +- Typed module clients come from `rudder client generate --lang ts`. +- Removed: `client.resources(slug)`, `client.projectStorage` and their types. -Breaking change — kernel surface. Module-specific client methods are removed. -The SDK now exposes the kernel only: auth, player (including wallets), catalog -currencies, storage, project storage, revision sync, -`client.modules.call(module, fn, args)`, and `client.resources(slug)`. +## 2.x (@rudder/js-sdk) -### Removed - -- `client.battlePass` / `BattlePassService` -- `client.quests` / `QuestsService` / `QuestMetrics` -- `client.leaderboards` / `LeaderboardsService` / `LeaderboardHandle` -- `client.stores` / `StoresDomain` / `ShopHandle` / `OfferHandle` -- `client.inventory` / `InventoryDomain` -- `client.remoteConfig` / `ConfigDomain` / `RemoteConfigState` -- Scenario runtime (`ScenarioService`, login-event trigger, pending poll) -- Module-specific effects: `onStoreOffer`, `onLeaderboard`, `onQuest`, - `onBattlePass`, `onBattlePassLevel`, `onConfigChanged` -- Generated wire types for battle pass, quests, stores, inventory, - leaderboards, remote config, and scenarios - -Call module functions with `client.modules.call(module, fn, args)` and read or -write resource rows with `client.resources(slug)` (`list` / `get` / `create` / -`update` / `delete`). - -Also required by the backend environments release: every project now has -exactly two environments, `staging` and `prod`, and the SDK key passed as -`projectKey` decides which one the player belongs to. The environment never -appears in the client API: it is resolved at login and carried inside the -access and refresh tokens. Tokens issued before this release have no -environment claim and are rejected with 401, so the first call after the -backend upgrade refreshes, fails, clears the token store and emits -`'signed-out'` to every `onAuthStateChange` listener. Log the player in again -with `client.auth.loginWithDevice()` / `loginWithCustom()`. - -## 1.0.0 - -- Scenario execution moved server-side. The SDK no longer walks a local DAG - or persists plan state to IndexedDB. -- Removed `RudderClientOptions.runtime.planStateStore` and the - `PlanStateStore` type. -- `GET /sdk/v1/scenarios/pending` is polled on login, on a ~30s heartbeat, - and at each effect `waitDeadline`. Completions POST - `/sdk/v1/scenarios/callback`; the response may carry the next - `PendingEffect`. -- Scenario `remote_config_override` nodes no longer emit `onConfigChanged` - (the server applies the override). The `ConfigChangedEffect` type and - `onConfigChanged` subscription remain. - -## 0.6.0 - -- `LeaderboardSession.claim()` / effect `claim()` completes the leaderboard - node with handle `onClaim`. The server matches live rank to an authored - place and continues from that place (Grant Reward and/or In-App Message). -- `rewardClaimed()` is a deprecated alias for `claim()` and will be removed - in the next SDK version. -- `rank_not_eligible` on Claim leaves the session open (retry Claim or End); - it no longer fails the run. - -## 0.5.1 - -- `QuestMetrics` / `reportProgress`: custom free-text metrics no longer - progress quests. Report a released catalog counter slug instead. - `purchaseOffer` / `purchaseItem` helpers are unchanged (server-side shop - fan-out). - -## 0.5.0 - -- `client.auth.loginWithCustom({ customData, region?, language?, nickname? })` - — custom webhook auth, same token + runtime start as `loginWithDevice`. -- `QuestMetrics.purchaseOffer` / `purchaseItem` helpers for the shop purchase - metric format. -- Typecheck against regenerated models: `listQuests` no longer takes a body, - battle-pass `track` is `'free' | 'premium'`, scenario run status has no - `unknown_run`. - -## 0.4.0 - -- Fixed `BattlePassLevelSession.level` reading node data key `level` — the - admin editor writes `levelNumber`, so tiers always reported `0`. The session - now reads `levelNumber`, matching the editor and server validation. -- UGC support removed from the generated wire types (`src/generated/ugc.ts` - deleted by apigen); no UGC surface was ever exposed by this SDK. -- Generated API types regenerated from the gateway openapi spec, adding the - new platform operations. - -## 0.3.0 - -Breaking changes — the package was aligned with the Rudder SDK glossary and -the regenerated wire types (`src/generated`, apigen). No compatibility shims -are provided. - -### Auth - -- `client.auth.loginViaDevice(region, language, nickname?)` replaced by - `client.auth.loginWithDevice(options?: { region?, language?, nickname? })`. - Default region is now `'global'` (was `'en'`); default language stays `'en'`. -- New `client.auth.onAuthStateChange(cb)` — fires immediately with the current - state, then on every `'signed-in' | 'signed-out'` transition (login, logout, - and unrecoverable session expiry in the transport). -- New `client.auth.isAuthenticated`. - -### Client configuration - -- `tokenStore` in `RudderClientOptions` is now optional. Default: - localStorage-backed store with a silent in-memory fallback where - localStorage is unavailable (SSR, private mode). New `createDefaultTokenStore()` export. -- Invalid client options (`baseUrl` / `projectKey` missing) now throw - `RudderError` with `code: 'sdk/invalid-options'` instead of a plain `Error`. -- New `onEffectError` option — called when an effect handler throws - (default: `console.error`); previously such errors were swallowed silently. - -### Renames - -- `client.config` → `client.remoteConfig` (canonical glossary name). -- `client.battlepass` → `client.battlePass`; `BattlepassService` → `BattlePassService`. -- Effects: `onBattlepass` / `BattlepassEffect` → `onBattlePass` / `BattlePassEffect`; - `onBattlepassLevel` / `BattlepassLevelEffect` → `onBattlePassLevel` / `BattlePassLevelEffect`. -- Generated types: `SdkQuest` → `Quest`; `*Battlepass*` request/response types → `*BattlePass*`. -- Internal transport `sendAsync` → `request` (not part of the public surface). - -### Leaderboards - -- Removed `LeaderboardsService.getRanking(slug, limit)` and - `LeaderboardsService.submitScore(slug, score)`. Single path: - `client.leaderboards.findBySlug(slug)` → `handle.list(limit?)` / `handle.submit(score)`. - -### Errors - -- `RudderNetworkError.inner` → standard `Error.cause`. -- `RudderHttpError.code` is typed as `RudderErrorCode | (string & {})` - (generated union of server error codes, plus room for unknown strings). -- New exports: `RudderErrorCode` (type), `RudderErrorCodes` (constants), - `SDK_ERROR_INVALID_OPTIONS`, `RudderErrorCodeLike` (type). - -### Public surface - -- The barrel no longer exports `getOrCreateDeviceId`, domain classes - (`PlayerDomain`, …) or service constructors (`AuthService`, …) as values — - they remain available as types. Instances live on the client. -- `SubmittedBy` is no longer re-exported (UGC is unsupported on web). - -### Bundle - -- The scenario engine and the IndexedDB plan store are loaded lazily (dynamic - import) on first login / first runtime access — a thin client never pulls - the scenario machinery into the initial chunk. -- The package now ships both ESM (`dist/index.js`) and CJS (`dist/index.cjs`) - builds with matching `exports` entries. +Deprecated. Its history is in git before the 0.1.0 commit. diff --git a/README.md b/README.md index 1ecaa8e..464900a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -# @rudder/js-sdk +# @rudder/sdk -LiveOps Web SDK for browser games — device auth, observable player state, -catalog currencies, storage, generic resources, and module function calls. +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 a private registry, not npmjs. Point the -`@rudder` scope at it in your project's `.npmrc`: +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/ @@ -15,50 +16,87 @@ The package is published to a private registry, not npmjs. Point the Then install as usual (anonymous read access, no token needed): ```bash -npm install @rudder/js-sdk +npm install @rudder/sdk ``` ## Quickstart ```ts -import { RudderClient } from '@rudder/js-sdk'; +import { createClient } from '@rudder/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). - appVersion: '1.4.2', // optional; sent with platform 'web' on login and refresh +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(); // region 'global', language 'en' -// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' }); +await client.auth.loginWithDevice(); -const profile = client.player.data; +const profile = client.player.value; const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance; +const wins = profile?.counters?.wins ?? 0; -const items = await client.resources('items').list(); -const { result } = await client.modules.call('my_module', 'hello', { name: 'Bob' }); +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 / wallets | `client.player` (observable) | -| Catalog currencies | `client.catalog` (observable) | -| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` | -| Project storage | `client.projectStorage` (observable) + `.save(items)` | -| Modules | `client.modules.call(module, fn, args?)` | -| Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` | +| 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 | -Game features are installed modules, not SDK services. The first-party -modules are items, inventory, reward, remote_config, leaderboards, quests, -store and battlepass. Call a module through `client.modules.call` and read its -rows through `client.resources(slug)`. +`counters` holds the values of the project's client-visible number counters +by slug; currency balances are in `wallets`. ## Error handling @@ -71,30 +109,31 @@ import { RudderNetworkError, RudderHttpError, RudderAuthError, -} from '@rudder/js-sdk'; + 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 + // 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 / timeout — the underlying error is in error.cause + // fetch failed or timed out; the underlying error is in error.cause console.error('offline?', error.cause); } } ``` -A module business error rejects with `RudderHttpError` (`status` 422) whose -`code` is the module's own open string code. The platform codes are -`forbidden` and `row_referenced`. +`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. -Invalid client options (missing `baseUrl` / `projectKey`) throw `RudderError` -with `code: 'sdk/invalid-options'` from the constructor. +A missing `projectKey` throws `RudderError` with +`code: 'sdk/invalid-options'` from `createClient`. Track the session lifecycle without polling the token store: @@ -107,7 +146,7 @@ client.auth.onAuthStateChange((state) => { ## Data liveness model -Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are +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. @@ -124,10 +163,10 @@ straight onto `useSyncExternalStore`: ```tsx import { useSyncExternalStore } from 'react'; -function useSynced(state: { onChange(cb: () => void): () => void; data: T | undefined }) { +function useSynced(state: { onChange(cb: () => void): () => void; value: T | undefined }) { return useSyncExternalStore( (onStoreChange) => state.onChange(() => onStoreChange()), - () => state.data, + () => state.value, ); } @@ -148,3 +187,11 @@ 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 +``` diff --git a/skills/rudder-web-sdk/SKILL.md b/skills/rudder-web-sdk/SKILL.md index e3e053d..b088cb5 100644 --- a/skills/rudder-web-sdk/SKILL.md +++ b/skills/rudder-web-sdk/SKILL.md @@ -1,30 +1,31 @@ --- name: rudder-web-sdk -description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/google/apple/custom auth, identity linking, observable kernel domains (player, catalog, storage), modules.call, resources(slug), and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears. +description: Use when working with the Rudder TypeScript/JavaScript client SDK (@rudder/sdk) for web games. Covers createClient, device/google/apple/custom auth, identity linking, state objects (player with wallets and counters, catalog, storage), module calls through clients generated by `rudder client generate`, idempotent retries, and error handling. Load this whenever code imports from @rudder/sdk or a generated rudder.modules.ts. --- -# Rudder Web SDK (@rudder/js-sdk) +# Rudder Web SDK (@rudder/sdk) -Browser-facing player SDK for the Rudder LiveOps platform. Source of truth: -`liveops-web-sdk/src` (generated wire types in `src/generated` come from -`liveops-backend/apigen` — never edit them by hand). +Client SDK for web games on the Rudder LiveOps platform. Source of truth: +`liveops-web-sdk/src` (wire types in `src/generated` come from +`liveops-backend/apigen`; never edit them by hand). -2.0 kernel surface: auth, player (wallets), catalog currencies, storage, -project storage, revision sync, `client.modules.call(module, fn, args)`, -and `client.resources(slug)`. Module-specific methods (battle pass, quests, -leaderboards, stores, inventory, remote config, scenarios) are gone. +0.1.0 covers only the kernel: auth, player, catalog, storage, module calls and +internal revision sync. Game features are installed modules, reached through a +typed client generated from their manifests. There is no `resources(slug)` and +no `projectStorage`; game-facing configuration comes from modules such as +`remote_config`. ## Install -Published to a private registry, not npmjs. Point the `@rudder` scope at it in -`.npmrc` (anonymous read, no token): +Published to the Gitea registry at hub.rudder.build, not npmjs. Point the +`@rudder` scope at it in `.npmrc` (anonymous read, no token): ``` @rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/ ``` ```bash -npm install @rudder/js-sdk +npm install @rudder/sdk ``` ESM + CJS + `.d.ts`; `type: module`; zero runtime dependencies. @@ -32,22 +33,21 @@ ESM + CJS + `.d.ts`; `type: module`; zero runtime dependencies. ## Init ```ts -import { RudderClient } from '@rudder/js-sdk'; +import { createClient } from '@rudder/sdk'; -const client = new RudderClient({ - baseUrl: 'https://api.rudder.build', // required - projectKey: 'your-project-key', // required +const client = createClient({ + projectKey: 'your-project-key', // required; selects staging or prod + // baseUrl?: string — default 'https://api.rudder.build' // tokenStore?: TokenStore — default: localStorage, in-memory fallback + // appVersion?: string — sent with platform 'web' on login/refresh // requestTimeoutMs?: number — default 10000 // syncIntervalMs?: number — revision poll, default 30000 (±20% jitter) }); ``` -Missing `baseUrl`/`projectKey` throws `RudderError` with -`code: 'sdk/invalid-options'` from the constructor. - -Call `client.dispose()` on unmount/HMR to stop the sync poll and drop cached -state. +A missing `projectKey` throws `RudderError` with +`code: 'sdk/invalid-options'`. Call `client.dispose()` on unmount/HMR to stop +the sync poll and drop cached state. ## Auth essentials @@ -65,12 +65,9 @@ client.auth.isAuthenticated; client.auth.onAuthStateChange(cb); ``` -Linked identities are listed on the profile: `client.player.data.identities` -(`PlayerProfileIdentity { provider, subject, createdAt }`). - Tokens are saved to the `TokenStore` automatically. The transport injects -`Authorization: Bearer `, retries GETs, and single-flight refreshes on -401; a failed refresh clears tokens and emits `signed-out`. Details: +`Authorization: Bearer ` and single-flight refreshes on 401; a failed +refresh clears tokens and emits `signed-out`. Details: [reference/auth.md](reference/auth.md). ## Client surface @@ -78,64 +75,68 @@ Tokens are saved to the `TokenStore` automatically. The transport injects | Area | Access | Kind | |---|---|---| | Auth | `client.auth` | service | -| Player profile + wallets | `client.player` | observable `SyncedState` | -| Catalog currencies | `client.catalog` | observable `SyncedState>` | -| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations | -| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation | -| Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionClientResponse` (`{ result }`) | -| Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` | +| Player | `client.player` (`player`, `identities`, `wallets`, `counters`) | `SyncedState` | +| Catalog currencies | `client.catalog` | `SyncedState>` | +| Player storage | `client.storage` + `.save(type, data)` / `.delete(type)` | `SyncedState` + mutations | +| Modules | `client.modules.call(module, fn, args?, { moduleVersion? })` | resolves to `result` as `T` | -Wallets live on `client.player.data.wallets`. Game features are installed -modules, not SDK services (first-party modules: items, inventory, reward, -remote_config, leaderboards, quests, store, battlepass). Call them with -`client.modules.call('my_module', 'hello', { ... })` and read their rows with -`client.resources(slug)`. Details: [reference/modules.md](reference/modules.md), -[reference/resources.md](reference/resources.md). +## Module calls through generated clients -## Observable domains — the shared pattern +```bash +RUDDER_URL=... RUDDER_TOKEN=... RUDDER_PROJECT=... \ + rudder client generate --lang ts --out src/rudder.modules.ts [--environment staging|prod] +``` -`player`, `catalog`, `storage`, and `projectStorage` all extend -`SyncedState`: +```ts +import { modules } from './rudder.modules'; -- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`, +const m = modules(client); +await m.leaderboards.submit({ slug: 'weekly', score: 120 }); +const top = await m.leaderboards.top({ slug: 'weekly', limit: 10 }); +``` + +The generated file is a factory over `client.modules.call`, with argument and +result types, and a `Error` code union per function. Regenerate +and commit it when installed modules change. Details: +[reference/modules.md](reference/modules.md). + +## State objects: the shared pattern + +`player`, `catalog` and `storage` extend `SyncedState`: + +- `.value: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`, `.error?: Error` -- `onChange(cb: (snapshot: SyncedSnapshot) => void): () => void` — fires - immediately with the current snapshot; maps onto React `useSyncExternalStore` -- `load(): Promise` — deduplicated, no-op when ready -- `reload(): Promise` — forced refetch +- `onChange(cb: (snapshot: SyncedSnapshot) => void): () => void`: fires + immediately with `{ status, value, error }`; maps onto React + `useSyncExternalStore` +- `load(): Promise`: deduplicated, no-op when ready +- `reload(): Promise`: forced refetch -`player` and `catalog` are warmed once at login; `storage` and -`projectStorage` load on first use. All observable domains are then kept fresh -by a revision poll every 30 s (±20% jitter); polling pauses while the tab is -hidden. Storage mutations invalidate their domain immediately. Call `reload()` -when you need a freshness guarantee. +`player` and `catalog` are warmed at login; `storage` loads on first use. A +revision poll (`GET /sdk/v1/sync`, keys `profile`, `catalog`, `storage`) runs +every 30 s (±20% jitter), pauses while the tab is hidden, and reloads the +states whose revision grew. Storage mutations invalidate immediately. Call +`player.reload()` after a module call that changes wallets or counters when +you need fresh values right away. ## Errors All SDK errors extend `RudderError` (optional machine-readable `code`): -- `RudderNetworkError` — fetch failed / timeout (`error.cause` holds the original) -- `RudderHttpError` — non-2xx, carries `status`, `statusText`, `body`, `code` -- `RudderAuthError extends RudderHttpError` — 401 after failed refresh; tokens - already cleared, `signed-out` already emitted +- `RudderNetworkError`: fetch failed or timed out (`error.cause` holds the original) +- `RudderHttpError`: non-2xx, carries `status`, `statusText`, `body`, `code` +- `RudderAuthError extends RudderHttpError`: 401 after a failed refresh; + tokens already cleared, `signed-out` already emitted +- `RudderModuleError extends RudderHttpError`: a module call returned 422 + `{ error, code, requestId }`; exposes `code: C`, `message`, `requestId` - `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`) - — constructor validation -The platform error codes are `forbidden` and `row_referenced` -(`src/generated/errors.ts`, not exported from the package entry); compare -`error.code === 'forbidden'`. - -`client.modules.call` resolves to `{ result }` only; console output and the -kernel call log never reach players. A module business error rejects with -`RudderHttpError` whose `status` is 422 and whose `code` is the module's own -code (the body is `{ error, code, requestId }`). Module codes are open strings -with no generated constants, so compare against the codes the module -documents. A module trap is a 500 and a timeout a 504, both without details. +Compare `error.code` with string literals. A module trap is a 500 and a +timeout a 504, both without details. ## Reference files -- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle -- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`) -- [reference/storage.md](reference/storage.md) — player + project storage, limits -- [reference/modules.md](reference/modules.md) — `modules.call`, response, module errors -- [reference/resources.md](reference/resources.md) — `resources(slug)` row operations +- [reference/auth.md](reference/auth.md): login flows, options, token store, session lifecycle +- [reference/player.md](reference/player.md): profile, wallets, counters +- [reference/storage.md](reference/storage.md): player storage, limits +- [reference/modules.md](reference/modules.md): generated clients, `modules.call`, idempotency, retries, errors diff --git a/skills/rudder-web-sdk/reference/auth.md b/skills/rudder-web-sdk/reference/auth.md index 022d5e3..4fe734e 100644 --- a/skills/rudder-web-sdk/reference/auth.md +++ b/skills/rudder-web-sdk/reference/auth.md @@ -101,7 +101,7 @@ await client.auth.unlinkIdentity('custom:my-backend'); `google`, `apple`, `custom`, or `custom:`. Linked identities appear on the player profile: -`client.player.data.identities: PlayerProfileIdentity[]` with +`client.player.value.identities: PlayerProfileIdentity[]` with `{ provider: string; subject: string; createdAt: string }`. ## Environments @@ -113,10 +113,6 @@ the client API takes an environment argument, and a player created in one environment is invisible in the other. Content released only to `staging` is empty for a `prod` key and vice versa. -Tokens issued before SDK 2.0.0 carry no environment claim and are rejected with -401. The transport's refresh then fails, clears the token store and emits -`'signed-out'` — log the player in again. - ## Auth state ```ts @@ -165,7 +161,7 @@ backends (sessionStorage, cookies). ## Errors -- Constructor: missing `baseUrl`/`projectKey` → `RudderError`, +- `createClient`: missing `projectKey` → `RudderError`, `code: 'sdk/invalid-options'`. - Login failure → `RudderHttpError` (e.g. unknown project key) or `RudderNetworkError`. diff --git a/skills/rudder-web-sdk/reference/modules.md b/skills/rudder-web-sdk/reference/modules.md index 1e9da57..95a3fd2 100644 --- a/skills/rudder-web-sdk/reference/modules.md +++ b/skills/rudder-web-sdk/reference/modules.md @@ -3,29 +3,75 @@ Source: `src/domains/ModulesDomain.ts`. Calls an installed module function as the signed-in player through `POST /sdk/v1/modules/{module}/{fn}`. -```ts -client.modules.call(module: string, fn: string, args?: unknown): Promise -// CallModuleFunctionClientResponse = { result?: unknown } +## Generated clients + +Use `rudder client generate` from `liveops-module-sdk/cli` instead of calling +modules by name: + +```bash +export RUDDER_URL=https://api.rudder.build RUDDER_TOKEN=... RUDDER_PROJECT=... +rudder client generate --lang ts --out src/rudder.modules.ts # staging by default +rudder client generate --lang ts --out src/rudder.modules.ts --environment prod +rudder client generate --lang ts --manifests ./modules --out src/rudder.modules.ts # local manifests ``` -```ts -const { result } = await client.modules.call('my_module', 'hello', { name: 'Bob' }); -``` +The file reads the manifests of the installed modules, generates only +`trigger: client` functions, and lists every module with its version in the +header. It exports: -`args` is sent as `{ args }`. The response carries only `result`; console -output and the kernel call log stay on the platform dry run. - -A business error raised by the module rejects with `RudderHttpError` -(`status` 422, body `{ error, code, requestId }`) whose `code` is the -module's own open string code; there are no generated constants for it. A -trap is a 500 and a timeout a 504, both without details. +- `modules(client)`: `{ : { (args): Promise } }` +- `Args` / `Result` types from the manifest schemas +- `Error`: the function's declared codes plus `RudderKernelError` ```ts +import { createClient, RudderModuleError } from '@rudder/sdk'; +import { modules, type StoreBuyError } from './rudder.modules'; + +const m = modules(createClient({ projectKey })); try { - await client.modules.call('my_module', 'hello'); + const result = await m.store.buy({ offer: 'starter_pack' }); } catch (error) { - if (error instanceof RudderHttpError && error.status === 422) { - console.warn(error.code); // module-defined + if (error instanceof RudderModuleError) { + switch (error.code as StoreBuyError) { + case 'insufficient_funds': /* ... */ break; + case 'offer_not_found': /* ... */ break; + } } } ``` + +Commit the generated file and regenerate it when installed modules change. +Do not edit it by hand. + +## `modules.call` + +```ts +client.modules.call( + module: string, + fn: string, + args?: unknown, + options?: { moduleVersion?: string }, +): Promise +``` + +- Sends `{ args }`; `args` is `{}` when omitted. Resolves to the response + `result`. Console output and the kernel call log never reach players. +- Idempotency: each call generates one `Idempotency-Key` (UUID v4) and sends + the same key on every retry, so the server runs the function at most once + and returns the stored response for a repeated key. A new `call()` gets a + new key. +- Retries: up to 2, with jittered exponential backoff, on network errors, 5xx + and 409 with code `conflict`. Other 4xx responses (including other 409 + codes) are never retried. +- `moduleVersion` (set by generated clients) is sent as + `X-Rudder-Module-Version: @`. A major mismatch with the + installed module is logged on the backend; the call still runs. + +## Errors + +- 422 `{ error, code, requestId }` rejects with `RudderModuleError` + (`status` 422, `code`, `message` from `error`, `requestId`). `C` defaults + to `string`; use the generated union to narrow it. +- A trap is a 500 and a timeout a 504, both `RudderHttpError` without details + (after the retries above). +- 401 follows the normal refresh flow (`RudderAuthError` when it fails). diff --git a/skills/rudder-web-sdk/reference/player.md b/skills/rudder-web-sdk/reference/player.md index 5532e12..d6eeeaa 100644 --- a/skills/rudder-web-sdk/reference/player.md +++ b/skills/rudder-web-sdk/reference/player.md @@ -1,7 +1,8 @@ -# Player profile + wallets — `client.player` +# Player — `client.player` `PlayerDomain` (source: `src/domains/PlayerDomain.ts`) — the observable player -profile: identity plus currency wallets. Synced under revision key `profile`. +profile: player, linked identities, currency wallets and +number counters. Synced under revision key `profile`. There is no separate wallet service in this SDK. Wallet balances are read from the profile and are credited/debited server-side (admin wallet adjust, module @@ -12,7 +13,7 @@ functions). `client.player` is a `SyncedState`: ```ts -client.player.data // PlayerProfile | undefined +client.player.value // PlayerProfile | undefined client.player.status // 'idle' | 'loading' | 'ready' | 'error' client.player.onChange((snapshot) => { /* fires immediately */ }); await client.player.load(); @@ -23,6 +24,7 @@ await client.player.reload(); // force refetch ```ts interface PlayerProfile { + counters?: { [slug: string]: number }; // client-visible number counters identities?: PlayerProfileIdentity[]; player?: Player; wallets?: Wallet[]; @@ -51,9 +53,12 @@ interface Wallet { ## Behavior notes +- `counters` holds the values of `number` counters whose definition is + `client_visible`; hidden counters are left out. Currency counters are in + `wallets`, not in `counters`. Counter changes bump the `profile` revision. - Warmed automatically at login (with catalog). - Kept fresh by the revision poll; call `reload()` after a module function - that mutates wallets. + that changes wallets or counters. - All fields are optional on the wire (`?`); code defensively. - React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK README "React recipe"). diff --git a/skills/rudder-web-sdk/reference/resources.md b/skills/rudder-web-sdk/reference/resources.md deleted file mode 100644 index a76b5d2..0000000 --- a/skills/rudder-web-sdk/reference/resources.md +++ /dev/null @@ -1,29 +0,0 @@ -# Resources - -Source: `src/domains/ResourcesDomain.ts`. Row operations on a resource as the -signed-in player through `/sdk/v1/resources/{slug}`. `client.resources(slug)` -returns a `ResourceHandle`; it performs no request by itself. - -```ts -const items = client.resources('items'); -items.list(): Promise // GET /{slug} → { items?: ResourceRow[], total?: number } -items.get(id): Promise // GET /{slug}/{id} -items.create({ id?, data? }): Promise // POST /{slug} -items.update(id, { data?, version? }): Promise // PATCH /{slug}/{id} -items.delete(id): Promise // DELETE /{slug}/{id} -``` - -`ResourceRow` is `{ id, playerId, data, version, rank, createdAt, updatedAt }` -(all optional in the generated type). Pass `version` on `update` for -optimistic concurrency. - -```ts -const row = await client.resources('items').create({ data: { name: 'Sword' } }); -await client.resources('items').update(row.id!, { data: { name: 'Axe' }, version: row.version }); -``` - -What a player may read or write is decided by the resource's access rules on -the server; a denied operation rejects with `RudderHttpError` `code` -`forbidden`. Deleting a row that other rows still reference rejects with -status 409 and `code` `row_referenced`. Resources are not observable domains: -call `list()` again when you need fresh rows. diff --git a/skills/rudder-web-sdk/reference/storage.md b/skills/rudder-web-sdk/reference/storage.md index b690c8d..85e4cc4 100644 --- a/skills/rudder-web-sdk/reference/storage.md +++ b/skills/rudder-web-sdk/reference/storage.md @@ -1,14 +1,11 @@ -# Storage — `client.storage` + `client.projectStorage` +# Storage — `client.storage` -Two key/value stores: per-player (`StorageDomain`, revision key `storage`) and -project-wide shared (`ProjectStorageDomain`, revision key `projectStorage`). -Sources: `src/domains/StorageDomain.ts`, `src/domains/ProjectStorageDomain.ts`. - -## Player storage +Per-player storage, one item per `type`. `StorageDomain`, revision key +`storage`. Source: `src/domains/StorageDomain.ts`. ```ts -client.storage.data // GetStorageResponse | undefined -await client.storage.save(items); +client.storage.value // GetStorageResponse | undefined +await client.storage.save(type, data); await client.storage.delete(type); ``` @@ -18,52 +15,21 @@ interface GetStorageResponse { nextCursor?: string; } interface StorageItem { - data?: string; // opaque payload, JSON-stringify yourself if needed - id?: string; - type?: string; // the storage "collection" key + data?: string; // opaque payload, JSON-stringify yourself + id?: string; // server-assigned + type?: string; // the storage key } ``` -- `save(items: StorageItem[]): Promise` — upserts items, then - invalidates the domain so subscribers refetch. -- `delete(type: string): Promise` — deletes **all** player storage items - of that type, then invalidates. - -## Project storage - -```ts -client.projectStorage.data // GetProjectStorageResponse | undefined -await client.projectStorage.save(items); -``` - -```ts -interface GetProjectStorageResponse { - items?: ProjectStorageItem[]; - nextCursor?: string; -} -interface ProjectStorageItem { - data?: string; - expiresAt?: string; - id?: string; - readPermission?: 'public' | 'serverOnly'; - size?: number; - type?: string; - updatedAt?: string; - version?: number; - writePermission?: 'public' | 'serverOnly'; -} -interface ProjectStorageUpdateItem { data?: string; type?: string } -``` - -- `save(items: ProjectStorageUpdateItem[]): Promise` — upserts, then - invalidates. Writing is only possible for items whose `writePermission` is - `public`; `serverOnly` items are read-only for clients. -- The SDK exposes no client-side project-storage delete. +- `save(type: string, data: string): Promise`: upserts the item of + `type` (the server keeps one item per player and type), then invalidates. +- `delete(type: string): Promise`: deletes the item of `type`, then + invalidates. ## Limits and notes -- Both domains load with `{ limit: 100 }` — the observable snapshot holds at - most 100 items and `nextCursor` pagination is not surfaced by the domain. -- `data` is a raw string on the wire; serialize/deserialize JSON yourself. -- Both are observable (`SyncedState`) and warmed by the revision poll only when - in use; mutations self-invalidate. +- The state loads with `{ limit: 100 }`; `nextCursor` pagination is not + surfaced. +- `data` is a raw string on the wire; serialize and parse JSON yourself. +- Project storage is not reachable from the client SDK. It stays available to + modules, the dashboard and `/game/v1`.