Initial commit
CI / check (push) Successful in 56s

This commit is contained in:
rudder
2026-08-12 14:02:25 +03:00
commit 3ab2d4a6cf
85 changed files with 10257 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install
run: npm ci
- name: Typecheck (src + tests)
run: npm run typecheck
- name: Unit + local e2e tests
run: npm test
- name: Build
run: npm run build
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
# Local seed artifact produced by the prod e2e suite (contains a throwaway operator token).
.e2e-prod.local.json
# IDE / OS
.idea/
.vscode/
.DS_Store
*.log
+65
View File
@@ -0,0 +1,65 @@
# Changelog
## 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.
+184
View File
@@ -0,0 +1,184 @@
# @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 |
|---|---|
| Auth | `client.auth.loginWithDevice()`, `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)` |
| 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
```
## 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 scenario engine (and its IndexedDB persistence) loads lazily on first
login — a client that only reads domains never pulls it into the page.
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.
+3007
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@rudder/js-sdk",
"version": "0.1.0",
"publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
},
"description": "LiveOps Web SDK for browser games — player authentication, leaderboards, stores, remote config, and typed runtime effects",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": false,
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://hub.rudder.build/rudder/rudder-js-sdk"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run test/e2e",
"test:e2e-prod": "vitest run --config vitest.e2e-prod.config.ts",
"e2e:prod": "vitest run --config vitest.e2e-prod.config.ts",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"check:package": "npm run build && npm pack --dry-run --json",
"generate": "make -C ../liveops-gateway generate-openapi"
},
"devDependencies": {
"@types/node": "^26.1.1",
"jsdom": "^29.1.1",
"tsup": "^8.3.5",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
}
}
+107
View File
@@ -0,0 +1,107 @@
/**
* AuthService — device-based player authentication.
*
* Handles login via device ID (the primary auth flow for game clients),
* logout (token clearing), and auth state observation.
*/
import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
import { getOrCreateDeviceId } from '../device/DeviceId.js';
import { api } from '../generated/api.js';
import type { LoginViaDeviceResponse } from '../generated/auth.js';
export type AuthState = 'signed-in' | 'signed-out';
export type AuthStateListener = (state: AuthState) => void;
export interface LoginWithDeviceOptions {
/** Player's region code (default: "global"). */
region?: string;
/** Player's language code (default: "en"). */
language?: string;
/** Optional player nickname; omitted from the request when not set. */
nickname?: string;
}
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
private readonly listeners = new Set<AuthStateListener>();
private state: AuthState;
constructor(
private readonly ctx: RudderContext,
private readonly startRuntime: () => Promise<void>,
private readonly stopRuntime: () => void,
) {
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
}
/** True while an access token is present in the token store. */
get isAuthenticated(): boolean {
return this.ctx.options.tokenStore.getAccessToken() !== null;
}
/**
* Subscribes to auth state changes. The listener fires immediately with the
* current state (like `SyncedState.onChange`). Returns an unsubscribe function.
*/
onAuthStateChange(listener: AuthStateListener): () => void {
this.listeners.add(listener);
// Resync with the token store — tokens may have been written externally
// since construction.
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
listener(this.state);
return () => {
this.listeners.delete(listener);
};
}
/**
* Authenticates the current device and returns access + refresh tokens.
*
* The device ID is auto-generated on first call and persisted in localStorage.
* On success, tokens are saved to the client's TokenStore.
*/
async loginWithDevice(options: LoginWithDeviceOptions = {}): Promise<LoginViaDeviceResponse> {
const { region = 'global', language = 'en', nickname } = options;
const response = await api.loginViaDevice(this.ctx, {
key: this.ctx.options.projectKey,
deviceId: getOrCreateDeviceId(),
region,
language,
nickname,
});
this.ctx.options.tokenStore.saveTokens(
response.accessToken ?? '',
response.refreshToken ?? '',
);
await this.startRuntime();
this.setState('signed-in');
return response;
}
/** Clears all stored tokens (logout). */
logout(): void {
this.ctx.options.tokenStore.clear();
this.stopRuntime();
this.setState('signed-out');
}
/**
* Called by the transport when the session is unrecoverable (refresh failed).
*
* @internal
*/
notifySessionExpired(): void {
this.setState('signed-out');
}
private setState(state: AuthState): void {
if (state === this.state) return;
this.state = state;
for (const listener of this.listeners) {
listener(state);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* BattlePassService — call-and-response access to the battle pass endpoints.
*
* Battle pass state is tied to a scenario battle pass node, so every call
* carries a `scenarioId` + `nodeId` (and a `runId` for the mutating calls).
* Battle pass has no sync-revision key, so this is a plain service, not an
* observable domain; re-fetch progress explicitly after a mutation.
*/
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type {
AddBattlePassXpRequest,
AddBattlePassXpResponse,
ClaimBattlePassRewardRequest,
ClaimBattlePassRewardResponse,
PurchaseBattlePassPremiumRequest,
PurchaseBattlePassPremiumResponse,
GetBattlePassProgressResponse,
} from '../generated/battlepass.js';
export class BattlePassService {
constructor(private readonly ctx: RudderContext) {}
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId });
}
/** Credits XP from a configured source. Returns the new xp/level. */
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse> {
return api.addBattlePassXp(this.ctx, request);
}
/** Claims a tier reward at a reached level (idempotent server-side). */
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse> {
return api.claimBattlePassReward(this.ctx, request);
}
/** Purchases the premium track (charges the wallet, idempotent). */
purchasePremium(
request: PurchaseBattlePassPremiumRequest,
): Promise<PurchaseBattlePassPremiumResponse> {
return api.purchaseBattlePassPremium(this.ctx, request);
}
}
+262
View File
@@ -0,0 +1,262 @@
/**
* RudderClient — the main entry point for the LiveOps Web SDK.
*
* Composes the observable domains, the scenario runtime, and the core HTTP
* transport that every service uses to reach the LiveOps API gateway.
*
* The client builds a {@link RudderContext} first and injects it into every
* domain and service, so they depend on that narrow surface rather than on the
* client (which keeps the module graph acyclic). Cross-domain wiring (the
* inventory↔catalog link, purchase invalidation) is done here at construction.
*
* The scenario runtime (engine + plan persistence) is loaded lazily on first
* use — a client that never logs in never pays for the scenario engine.
*/
import type {
ResolvedRudderClientOptions,
RudderClientOptions,
} from './RudderClientOptions.js';
import { RudderError, SDK_ERROR_INVALID_OPTIONS } from './RudderError.js';
import type { RudderContext } from '../core/context.js';
import { request, type RequestContext } from '../transport/request.js';
import { createDefaultTokenStore } from '../token/TokenStore.js';
import { AuthService } from '../auth/AuthService.js';
import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
import { BattlePassService } from '../battlepass/BattlePassService.js';
import { QuestsService } from '../quests/QuestsService.js';
import type { ScenarioService } from '../scenario/ScenarioService.js';
import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
import { SyncEngine } from '../state/SyncEngine.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
import { PlayerDomain } from '../domains/PlayerDomain.js';
import { CatalogDomain } from '../domains/CatalogDomain.js';
import { InventoryDomain } from '../domains/InventoryDomain.js';
import { ConfigDomain } from '../domains/ConfigDomain.js';
import { StorageDomain } from '../domains/StorageDomain.js';
import { StoresDomain } from '../domains/StoresDomain.js';
/** A domain the client can bulk-invalidate (re-login) or reset (logout). */
type ManagedDomain = { invalidate(): void; reset(): void };
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
public readonly options: ResolvedRudderClientOptions;
public readonly auth: AuthService<TConfig>;
public readonly leaderboards: LeaderboardsService;
public readonly battlePass: BattlePassService;
public readonly quests: QuestsService;
public readonly effects: Effects;
// Observable domains: subscribe via `onChange`, read `.data`, or `reload()`.
public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain;
public readonly inventory: InventoryDomain;
public readonly remoteConfig: ConfigDomain<TConfig>;
public readonly storage: StorageDomain;
public readonly stores: StoresDomain;
private readonly effectsCenter: EffectsCenter;
private scenarioRuntime: ScenarioService<TConfig> | null = null;
private scenarioRuntimePromise: Promise<ScenarioService<TConfig>> | null = null;
private readonly syncEngine: SyncEngine;
private readonly loginEvent: string | null;
private readonly ctx: RudderContext;
private readonly transportContext: RequestContext;
private runtimeStarted = false;
constructor(options: RudderClientOptions) {
if (!options.baseUrl) {
throw new RudderError('RudderClient: baseUrl is required', {
code: SDK_ERROR_INVALID_OPTIONS,
});
}
if (!options.projectKey) {
throw new RudderError('RudderClient: projectKey is required', {
code: SDK_ERROR_INVALID_OPTIONS,
});
}
this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() };
this.loginEvent =
options.runtime?.loginEvent === undefined ? 'player_login' : options.runtime.loginEvent;
this.transportContext = {
baseUrl: this.options.baseUrl,
tokenStore: this.options.tokenStore,
requestTimeoutMs: this.options.requestTimeoutMs,
refreshState: {},
onAuthFailure: () => {
this.auth.notifySessionExpired();
this.stopRuntimeAfterLogout();
},
};
this.effectsCenter = new EffectsCenter(this.options.onEffectError);
this.effects = this.effectsCenter;
this.ctx = {
options: this.options,
effects: this.effectsCenter,
request: <TResponse>(method: string, path: string, body?: unknown) =>
request<TResponse>(method, path, body, this.transportContext),
};
const ctx = this.ctx;
this.player = new PlayerDomain(ctx);
this.catalog = new CatalogDomain(ctx);
this.inventory = new InventoryDomain(ctx, this.catalog);
this.remoteConfig = new ConfigDomain<TConfig>(ctx);
this.storage = new StorageDomain(ctx);
this.stores = new StoresDomain(ctx, () => {
this.player.invalidate();
this.inventory.invalidate();
this.stores.invalidate();
});
this.leaderboards = new LeaderboardsService(ctx);
this.battlePass = new BattlePassService(ctx);
this.quests = new QuestsService(ctx);
this.syncEngine = new SyncEngine(ctx, [
this.player,
this.catalog,
this.inventory,
this.remoteConfig,
this.storage,
this.stores,
]);
this.auth = new AuthService<TConfig>(
ctx,
() => this.startRuntimeAfterLogin(),
() => this.stopRuntimeAfterLogout(),
);
}
/**
* Tears down all background work: stops the sync poll (and its
* visibilitychange listener), cancels scenario wait timers, and drops cached
* state. Call when disposing the client (e.g. on unmount / HMR) to avoid leaks.
*/
dispose(): void {
this.syncEngine.stop();
this.scenarioRuntime?.clear();
this.resetAll();
this.runtimeStarted = false;
}
/** @internal */
async startRuntimeAfterLogin(): Promise<void> {
const runtimePromise = this.ensureScenarioRuntime();
if (this.runtimeStarted) {
this.scenarioRuntime?.clear();
this.invalidateAll();
}
this.runtimeStarted = true;
// These four warms are independent — fetch them concurrently so login
// doesn't pay a serial round-trip per entity.
await Promise.all([
this.remoteConfig.load(),
this.player.load(),
this.stores.load(),
this.catalog.load(),
]);
const runtime = await runtimePromise;
await runtime.restore();
if (this.loginEvent) {
await this.sendRuntimeEvent(runtime, this.loginEvent);
}
this.syncEngine.start();
}
/**
* Loads the scenario engine and plan persistence on first use and caches the
* runtime. Dynamic imports keep the engine out of the initial module graph,
* so a thin client never pulls the scenario machinery.
*/
private ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> {
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
return this.scenarioRuntimePromise;
}
private async createScenarioRuntime(): Promise<ScenarioService<TConfig>> {
const { ScenarioService } = await import('../scenario/ScenarioService.js');
const configured = this.options.runtime?.planStateStore;
let planStore: PlanStateStore | null;
if (configured !== undefined) {
planStore = configured;
} else {
const { createIndexedDbPlanStateStore } = await import(
'../scenario/engine/IndexedDbPlanStore.js'
);
planStore = createIndexedDbPlanStateStore();
}
this.scenarioRuntime = new ScenarioService<TConfig>(
this.ctx,
{
player: this.player,
inventory: this.inventory,
config: this.remoteConfig,
stores: this.stores,
},
this.battlePass,
planStore,
);
return this.scenarioRuntime;
}
private async sendRuntimeEvent(
runtime: ScenarioService<TConfig>,
event: string,
): Promise<void> {
try {
await runtime.send(event);
} catch (error) {
console.warn(`RudderClient: scenario event "${event}" failed after login`, error);
}
}
/** @internal */
stopRuntimeAfterLogout(): void {
this.runtimeStarted = false;
this.syncEngine.stop();
this.scenarioRuntime?.clear();
this.resetAll();
}
private get managedDomains(): ManagedDomain[] {
return [
this.player,
this.catalog,
this.inventory,
this.remoteConfig,
this.storage,
this.stores,
];
}
/** Re-login: refetch every domain that is in use. */
private invalidateAll(): void {
for (const domain of this.managedDomains) domain.invalidate();
}
/** Logout: drop all cached data. */
private resetAll(): void {
for (const domain of this.managedDomains) domain.reset();
}
}
/**
* Test/internal access to the scenario runtime, which is deliberately not part
* of the public client surface (it is driven through `client.effects`).
* Lazily loads the scenario engine on first call.
*
* @internal
*/
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
client: RudderClient<TConfig>,
): Promise<ScenarioService<TConfig>> {
return (
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> }
).ensureScenarioRuntime();
}
+56
View File
@@ -0,0 +1,56 @@
import type { TokenStore } from '../token/TokenStore.js';
import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
/**
* Advanced runtime knobs. Mainly for tests and non-browser hosts; browser
* consumers can ignore these and take the defaults.
*/
export interface RudderRuntimeOptions {
/**
* Scenario plan persistence backend. `undefined` uses the default
* IndexedDB store (created lazily with the scenario runtime); pass `null`
* to disable persistence entirely.
*/
planStateStore?: PlanStateStore | null;
/**
* Scenario event fired automatically after login. `undefined` uses
* `'player_login'`; pass `null` to fire nothing.
*/
loginEvent?: string | null;
}
export interface RudderClientOptions {
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
baseUrl: string;
/** Project key that identifies the game project. */
projectKey: string;
/**
* Token store for persisting and retrieving authentication tokens.
* Defaults to localStorage with a silent in-memory fallback where
* localStorage is unavailable (SSR, private mode).
*/
tokenStore?: TokenStore;
/** Per-request timeout in milliseconds (default: 10000). */
requestTimeoutMs?: number;
/** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */
syncIntervalMs?: number;
/**
* Called when an effect handler (or effect preparation) throws.
* Defaults to `console.error`.
*/
onEffectError?: (error: unknown) => void;
/** Advanced runtime knobs (plan persistence, login event). */
runtime?: RudderRuntimeOptions;
}
/** Client options with all defaults resolved — what `client.options` exposes. */
export interface ResolvedRudderClientOptions extends RudderClientOptions {
tokenStore: TokenStore;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Typed error hierarchy for the LiveOps Web SDK.
*
* All errors extend `RudderError`. Network errors, HTTP errors, and
* authentication errors have dedicated subclasses for granular handling.
* The machine-readable `code` is the server-provided {@link RudderErrorCode}
* when known, or any other string for unknown servers / SDK-local codes
* (e.g. `sdk/invalid-options` for client configuration errors).
*/
import type { RudderErrorCode } from '../generated/errors.js';
/** Code carried by SDK errors: a known server code, or any other string. */
export type RudderErrorCodeLike = RudderErrorCode | (string & {});
/** Code for client-side configuration validation failures. */
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
/** Base error class for all SDK errors. */
export class RudderError extends Error {
public readonly code?: RudderErrorCodeLike;
constructor(message: string, options?: { cause?: unknown; code?: RudderErrorCodeLike }) {
super(message, options);
this.name = 'RudderError';
this.code = options?.code;
}
}
/** Thrown when a network request fails (fetch throws, timeout, DNS, etc.). */
export class RudderNetworkError extends RudderError {
constructor(cause: unknown) {
super(`Network error: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
this.name = 'RudderNetworkError';
}
}
/** Thrown when the server returns a non-2xx HTTP status (except 401). */
export class RudderHttpError extends RudderError {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: string,
code?: RudderErrorCodeLike,
) {
super(`HTTP ${status} ${statusText}`, { code });
this.name = 'RudderHttpError';
}
}
/** Thrown when the server returns HTTP 401 Unauthorized. */
export class RudderAuthError extends RudderHttpError {
constructor(statusText: string, body: string, code?: RudderErrorCodeLike) {
super(401, statusText, body, code);
this.name = 'RudderAuthError';
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js';
import type { EffectsCenter } from '../effects/EffectsCenter.js';
/**
* RudderContext — the low-level surface every feature service depends on:
* the HTTP transport, client options, and the effects bus.
*
* Built once by {@link RudderClient} and injected into each service. Services
* depend on this narrow interface instead of on the client itself, so the
* module graph stays acyclic (client → services → context, never back).
*/
export interface RudderContext {
readonly options: ResolvedRudderClientOptions;
readonly effects: EffectsCenter;
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Device ID provider — generates and persists a unique device identifier.
*
* Uses `crypto.randomUUID()` (available in all evergreen browsers) and
* stores the generated UUID in localStorage under `rudder_device_id`.
*/
const STORAGE_KEY = 'rudder_device_id';
/**
* Returns the stored device ID or creates a new one if none exists.
* The device ID is persisted in localStorage and survives page reloads.
*/
export function getOrCreateDeviceId(): string {
try {
const existing = localStorage.getItem(STORAGE_KEY);
if (existing) return existing;
} catch {
// localStorage unavailable — generate ephemeral ID.
}
const id = crypto.randomUUID();
try {
localStorage.setItem(STORAGE_KEY, id);
} catch {
// Storage unavailable — ID will be ephemeral for this session.
}
return id;
}
+24
View File
@@ -0,0 +1,24 @@
import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { CatalogItem } from '../generated/catalog.js';
/**
* CatalogDomain — the item catalog, keyed by slug. Synced under revision
* key `catalog`. Feeds the merged {@link InventoryDomain} view.
*/
export class CatalogDomain extends SyncedState<Map<string, CatalogItem>> {
readonly syncKey = 'catalog';
constructor(ctx: RudderContext) {
super(async () => {
const response = await api.listCatalogItems(ctx);
const map = new Map<string, CatalogItem>();
for (const item of response.items ?? []) {
if (!item.slug) continue;
map.set(item.slug, item);
}
return map;
});
}
}
+33
View File
@@ -0,0 +1,33 @@
import { RemoteConfigState, type RemoteConfigShape } from '../state/RemoteConfigState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { RemoteConfig } from '../generated/remote-config.js';
/**
* ConfigDomain — typed remote configuration as an observable entity. Synced
* under revision key `config`. Extends {@link RemoteConfigState}, which adds
* typed `get()` and scenario override support.
*/
export class ConfigDomain<
TConfig extends RemoteConfigShape = RemoteConfigShape,
> extends RemoteConfigState<TConfig> {
readonly syncKey = 'config';
constructor(ctx: RudderContext) {
super(async () => {
const response = await api.listSdkRemoteConfigs(ctx);
const map = new Map<string, RemoteConfig>();
if (response.configs) {
for (const [key, config] of Object.entries(response.configs)) {
// The server already filters inactive configs out (the game cache skips
// !active) and may omit `active` on the wire, so keep anything that is
// not explicitly inactive instead of dropping configs that carry no flag.
if (config && config.active !== false) {
map.set(config.key ?? key, config);
}
}
}
return map;
});
}
}
+31
View File
@@ -0,0 +1,31 @@
import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import { mergeInventory, type InventoryItem } from '../state/inventory.js';
import type { CatalogDomain } from './CatalogDomain.js';
/**
* InventoryDomain — owned items merged with their catalog entries. Synced
* under revision key `inventory`; also refreshes when the catalog changes.
*/
export class InventoryDomain extends SyncedState<InventoryItem[]> {
readonly syncKey = 'inventory';
constructor(ctx: RudderContext, catalog: CatalogDomain) {
super(async () => {
const [response, catalogItems] = await Promise.all([
api.getInventory(ctx),
catalog.load(),
]);
return mergeInventory(response.items ?? [], catalogItems);
});
// Catalog changes flow into the merged inventory view.
let previousCatalog = catalog.data;
catalog.onChange((snapshot) => {
if (snapshot.data === previousCatalog) return;
previousCatalog = snapshot.data;
this.invalidate();
});
}
}
+16
View File
@@ -0,0 +1,16 @@
import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { PlayerProfile } from '../generated/player.js';
/**
* PlayerDomain — the observable player profile (identity + wallets).
* Synced under revision key `profile`.
*/
export class PlayerDomain extends SyncedState<PlayerProfile> {
readonly syncKey = 'profile';
constructor(ctx: RudderContext) {
super(() => api.getPlayerInformation(ctx));
}
}
+29
View File
@@ -0,0 +1,29 @@
import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { GetStorageResponse, StorageItem } from '../generated/storage.js';
/**
* StorageDomain — player key/value storage as an observable entity, plus its
* mutations. Synced under revision key `storage`; mutations invalidate it so
* subscribers observe fresh data.
*/
export class StorageDomain extends SyncedState<GetStorageResponse> {
readonly syncKey = 'storage';
constructor(private readonly ctx: RudderContext) {
super(() => api.getStorage(ctx, { limit: 100 }));
}
/** Saves player storage items, then invalidates. */
async save(items: StorageItem[]): Promise<void> {
await api.updateStorage(this.ctx, { items });
this.invalidate();
}
/** Deletes player storage items of the given type, then invalidates. */
async delete(type: string): Promise<void> {
await api.deleteStorage(this.ctx, { type });
this.invalidate();
}
}
+62
View File
@@ -0,0 +1,62 @@
import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import { ShopHandle, type BuyOptions, type PurchaseOffer } from '../state/shops.js';
import type { PurchaseOfferResponse } from '../generated/stores.js';
/**
* StoresDomain — the observable list of stores, plus the purchase executor
* that every offer/store handle shares. Synced under revision key `stores`.
*
* A successful purchase runs `onPurchase`, which the client wires to invalidate
* the profile, inventory, and store list so subscribers observe fresh data.
*/
export class StoresDomain extends SyncedState<ShopHandle[]> {
readonly syncKey = 'stores';
/** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */
readonly purchase: PurchaseOffer;
private readonly ctx: RudderContext;
constructor(ctx: RudderContext, onPurchase: () => void) {
// Built before super() so it captures params, not `this`; the store/offer
// handles created in the loader all delegate to this one executor.
const purchase: PurchaseOffer = async (
storeSlug: string,
offerId: string,
options: BuyOptions = {},
): Promise<PurchaseOfferResponse> => {
const response = await api.purchaseOffer(
ctx,
{ storeSlug, offerId },
{
storeSlug,
offerId,
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
},
);
onPurchase();
return response;
};
super(async () => {
const response = await api.listSdkStores(ctx);
return (response.stores ?? []).map((store) => new ShopHandle(purchase, store));
});
this.ctx = ctx;
this.purchase = purchase;
}
/**
* Resolves a store by slug (cache-first). Used by scenario store nodes.
* @internal
*/
async getBySlug(slug: string): Promise<ShopHandle> {
const cached = this.data?.find((store) => store.slug === slug);
if (cached) return cached;
const store = await api.getStore(this.ctx, { slug });
return new ShopHandle(this.purchase, store);
}
}
+277
View File
@@ -0,0 +1,277 @@
import type { PurchaseOfferResponse } from '../generated/stores.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js';
import type {
BattlePassLevelSession,
BattlePassSession,
LeaderboardSession,
NotificationSession,
QuestSession,
StoreSession,
WaitSession,
} from '../scenario/engine/sessions.js';
import type { PlanRun, ScenarioRunFailedEvent } from '../scenario/engine/types.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
GetBattlePassProgressResponse,
PurchaseBattlePassPremiumResponse,
} from '../generated/battlepass.js';
export type EffectUnsubscribe = () => void;
export interface NotificationEffect {
readonly title: string;
readonly message: string;
done(): Promise<void>;
}
export interface StoreOfferEffect {
readonly store: ShopHandle;
readonly offers: readonly OfferHandle[];
readonly message?: string;
buy(offer: OfferHandle, options?: BuyOptions): Promise<PurchaseOfferResponse>;
dismiss(): Promise<void>;
}
export interface LeaderboardEffect {
end(): Promise<void>;
rewardClaimed(): Promise<void>;
}
export interface ConfigChangedEffect {
readonly key: string;
}
/** A scenario wait node became active; the run resumes at `deadlineUtc`. */
export interface WaitEffect {
readonly deadlineUtc: Date;
}
/** A scenario run reached a terminal end successfully. */
export interface ScenarioCompletedEffect {
readonly runId: string;
readonly scenarioId: string;
}
/**
* A scenario run failed at a node (transport gave up, or the node type is not
* supported by this SDK). Surfaced so the game can react instead of the failure
* being swallowed into a console warning.
*/
export interface ScenarioFailedEffect {
readonly runId: string;
readonly scenarioId: string;
readonly nodeId: string;
readonly error: Error;
}
/**
* A scenario quest node became active. Report objective progress; the node
* auto-completes server-side once every objective is satisfied.
*/
export interface QuestEffect {
readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>;
}
/** A scenario battle pass node became active. */
export interface BattlePassEffect {
getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
claimReward(level: number, track?: string): Promise<ClaimBattlePassRewardResponse>;
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
levelUp(): Promise<void>;
end(): Promise<void>;
}
/** A scenario battlepass_level node became active (a single claimable tier). */
export interface BattlePassLevelEffect {
readonly level: number;
claim(): Promise<void>;
}
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
export interface Effects {
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe;
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe;
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe;
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe;
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe;
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe;
}
export class EffectsCenter implements Effects {
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
private readonly storeOfferHandlers = new Set<EffectHandler<StoreOfferEffect>>();
private readonly leaderboardHandlers = new Set<EffectHandler<LeaderboardEffect>>();
private readonly configChangedHandlers = new Set<EffectHandler<ConfigChangedEffect>>();
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
private readonly questHandlers = new Set<EffectHandler<QuestEffect>>();
private readonly battlePassHandlers = new Set<EffectHandler<BattlePassEffect>>();
private readonly battlePassLevelHandlers = new Set<EffectHandler<BattlePassLevelEffect>>();
constructor(
private readonly onError: (error: unknown) => void = (error) =>
console.error('[Rudder] Effect handler failed', error),
) {}
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe {
return this.addHandler(this.notificationHandlers, handler);
}
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe {
return this.addHandler(this.storeOfferHandlers, handler);
}
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe {
return this.addHandler(this.leaderboardHandlers, handler);
}
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe {
return this.addHandler(this.configChangedHandlers, handler);
}
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
return this.addHandler(this.waitHandlers, handler);
}
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe {
return this.addHandler(this.scenarioCompletedHandlers, handler);
}
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe {
return this.addHandler(this.scenarioFailedHandlers, handler);
}
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe {
return this.addHandler(this.questHandlers, handler);
}
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe {
return this.addHandler(this.battlePassHandlers, handler);
}
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe {
return this.addHandler(this.battlePassLevelHandlers, handler);
}
/** @internal */
emitNotification(session: NotificationSession): void {
this.emit(this.notificationHandlers, {
title: session.title,
message: session.message,
done: () => session.complete(),
});
}
/** @internal */
emitStoreOffer(session: StoreSession): void {
session.getStore()
.then((store) => {
this.emit(this.storeOfferHandlers, {
store,
offers: store.offers,
message: session.get('message', undefined as string | undefined),
buy: (offer, options) => session.buy(offer, options),
dismiss: () => session.decline(),
});
})
.catch((error) => this.onError(error));
}
/** @internal */
emitLeaderboard(session: LeaderboardSession): void {
this.emit(this.leaderboardHandlers, {
end: () => session.end(),
rewardClaimed: () => session.rewardClaimed(),
});
}
/** @internal */
emitConfigChanged(key: string): void {
this.emit(this.configChangedHandlers, { key });
}
/** @internal */
emitWait(session: WaitSession): void {
this.emit(this.waitHandlers, { deadlineUtc: session.deadlineUtc });
}
/** @internal */
emitScenarioCompleted(run: PlanRun): void {
this.emit(this.scenarioCompletedHandlers, {
runId: run.runId,
scenarioId: run.scenarioId,
});
}
/** @internal */
emitScenarioFailed(event: ScenarioRunFailedEvent): void {
this.emit(this.scenarioFailedHandlers, {
runId: event.run.runId,
scenarioId: event.run.scenarioId,
nodeId: event.nodeId,
error: event.error,
});
}
/** @internal */
emitQuest(session: QuestSession): void {
this.emit(this.questHandlers, {
name: session.name,
objectives: session.objectives,
reportProgress: (objectiveId, amount) => session.reportProgress(objectiveId, amount),
});
}
/** @internal */
emitBattlePass(session: BattlePassSession): void {
this.emit(this.battlePassHandlers, {
getProgress: () => session.getProgress(),
addXp: (source, amount) => session.addXp(source, amount),
claimReward: (level, track) => session.claimReward(level, track),
purchasePremium: () => session.purchasePremium(),
levelUp: () => session.levelUp(),
end: () => session.end(),
});
}
/** @internal */
emitBattlePassLevel(session: BattlePassLevelSession): void {
this.emit(this.battlePassLevelHandlers, {
level: session.level,
claim: () => session.claim(),
});
}
private addHandler<TEffect>(
handlers: Set<EffectHandler<TEffect>>,
handler: EffectHandler<TEffect>,
): EffectUnsubscribe {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
}
private emit<TEffect>(
handlers: Set<EffectHandler<TEffect>>,
effect: TEffect,
): void {
for (const handler of handlers) {
try {
Promise.resolve(handler(effect)).catch((error) => this.onError(error));
} catch (error) {
this.onError(error);
}
}
}
}
+228
View File
@@ -0,0 +1,228 @@
// Code generated by apigen. DO NOT EDIT.
import type { LoginViaDeviceRequest, LoginViaDeviceResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse } from './auth.js';
import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js';
import type { ListCatalogItemsResponse } from './catalog.js';
import type { GetInventoryResponse } from './inventory.js';
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
import type { PlayerProfile } from './player.js';
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsRequest, ListQuestsResponse } from './quests.js';
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
import type { GetScenarioRunRequest, GetScenarioRunResponse, HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
import type { DeleteUgcResponse, GetDownloadUrlResponse, GetUploadUrlResponse, ListUgcResponse, SubmitUgcRequest, UgcSubmission } from './ugc.js';
/** The minimal transport contract the generated API functions call through. */
export interface Transport {
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
}
function enc(value: string | number): string {
return encodeURIComponent(String(value));
}
function qs(
params: Array<[string, string | number | boolean | null | undefined]>,
): string {
const parts: string[] = [];
for (const [key, value] of params) {
if (value !== null && value !== undefined && value !== '') {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}
}
return parts.length === 0 ? '' : `?${parts.join('&')}`;
}
/** Typed request functions, one per SDK operation. */
export const api = {
addBattlePassXp: (
t: Transport,
body: AddBattlePassXpRequest,
): Promise<AddBattlePassXpResponse> =>
t.request<AddBattlePassXpResponse>('POST', '/sdk/v1/battlepass/xp', body),
claimBattlePassReward: (
t: Transport,
body: ClaimBattlePassRewardRequest,
): Promise<ClaimBattlePassRewardResponse> =>
t.request<ClaimBattlePassRewardResponse>('POST', '/sdk/v1/battlepass/claim', body),
claimQuest: (
t: Transport,
body: ClaimQuestRequest,
): Promise<ClaimQuestResponse> =>
t.request<ClaimQuestResponse>('POST', '/sdk/v1/quests/claim', body),
deleteStorage: (
t: Transport,
query?: { type?: string },
): Promise<void> =>
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
deleteUgc: (
t: Transport,
path: { id: string },
): Promise<DeleteUgcResponse> =>
t.request<DeleteUgcResponse>('DELETE', `/sdk/v1/ugc/${enc(path.id)}`),
getBattlePassProgress: (
t: Transport,
body: GetBattlePassProgressRequest,
): Promise<GetBattlePassProgressResponse> =>
t.request<GetBattlePassProgressResponse>('POST', '/sdk/v1/battlepass/progress', body),
getDownloadUrl: (
t: Transport,
path: { id: string },
): Promise<GetDownloadUrlResponse> =>
t.request<GetDownloadUrlResponse>('GET', `/sdk/v1/ugc/${enc(path.id)}/download`),
getInventory: (
t: Transport,
): Promise<GetInventoryResponse> =>
t.request<GetInventoryResponse>('GET', '/sdk/v1/inventory'),
getPlayerInformation: (
t: Transport,
): Promise<PlayerProfile> =>
t.request<PlayerProfile>('GET', '/sdk/v1/player/information'),
getRanking: (
t: Transport,
path: { slug: string },
query?: { limit?: number },
): Promise<GetRankingResponse> =>
t.request<GetRankingResponse>('GET', `/sdk/v1/leaderboards/${enc(path.slug)}/ranking${qs([['limit', query?.limit]])}`),
getRemoteConfig: (
t: Transport,
path: { key: string },
): Promise<RemoteConfig> =>
t.request<RemoteConfig>('GET', `/sdk/v1/remote-configs/${enc(path.key)}`),
getRevisions: (
t: Transport,
): Promise<{ [key: string]: number }> =>
t.request<{ [key: string]: number }>('GET', '/sdk/v1/sync'),
getScenarioRun: (
t: Transport,
body: GetScenarioRunRequest,
): Promise<GetScenarioRunResponse> =>
t.request<GetScenarioRunResponse>('POST', '/sdk/v1/scenarios/run', body),
getStorage: (
t: Transport,
query?: { types?: string; limit?: number; cursor?: string },
): Promise<GetStorageResponse> =>
t.request<GetStorageResponse>('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
getStore: (
t: Transport,
path: { slug: string },
): Promise<Store> =>
t.request<Store>('GET', `/sdk/v1/stores/${enc(path.slug)}`),
getUgc: (
t: Transport,
path: { id: string },
): Promise<UgcSubmission> =>
t.request<UgcSubmission>('GET', `/sdk/v1/ugc/${enc(path.id)}`),
getUploadUrl: (
t: Transport,
query?: { filename?: string },
): Promise<GetUploadUrlResponse> =>
t.request<GetUploadUrlResponse>('GET', `/sdk/v1/ugc/upload-url${qs([['filename', query?.filename]])}`),
handleScenarioCallback: (
t: Transport,
body: HandleScenarioCallbackRequest,
): Promise<HandleScenarioCallbackResponse> =>
t.request<HandleScenarioCallbackResponse>('POST', '/sdk/v1/scenarios/callback', body),
listCatalogItems: (
t: Transport,
): Promise<ListCatalogItemsResponse> =>
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
listQuests: (
t: Transport,
body: ListQuestsRequest,
): Promise<ListQuestsResponse> =>
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list', body),
listSdkRemoteConfigs: (
t: Transport,
): Promise<ListRemoteConfigsResponse> =>
t.request<ListRemoteConfigsResponse>('GET', '/sdk/v1/remote-configs'),
listSdkStores: (
t: Transport,
): Promise<ListStoresResponse> =>
t.request<ListStoresResponse>('GET', '/sdk/v1/stores'),
listUgc: (
t: Transport,
query?: { status?: string; limit?: number; cursor?: string },
): Promise<ListUgcResponse> =>
t.request<ListUgcResponse>('GET', `/sdk/v1/ugc${qs([['status', query?.status], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
loginViaDevice: (
t: Transport,
body: LoginViaDeviceRequest,
): Promise<LoginViaDeviceResponse> =>
t.request<LoginViaDeviceResponse>('POST', '/sdk/v1/authorization/device', body),
purchaseBattlePassPremium: (
t: Transport,
body: PurchaseBattlePassPremiumRequest,
): Promise<PurchaseBattlePassPremiumResponse> =>
t.request<PurchaseBattlePassPremiumResponse>('POST', '/sdk/v1/battlepass/premium', body),
purchaseOffer: (
t: Transport,
path: { storeSlug: string; offerId: string },
body: PurchaseOfferRequest,
): Promise<PurchaseOfferResponse> =>
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerId)}/purchase`, body),
refreshAccessToken: (
t: Transport,
body: RefreshAccessTokenRequest,
): Promise<RefreshAccessTokenResponse> =>
t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body),
submitScore: (
t: Transport,
path: { slug: string },
body: SubmitScoreRequest,
): Promise<void> =>
t.request<void>('POST', `/sdk/v1/leaderboards/${enc(path.slug)}/submit-score`, body),
submitUgc: (
t: Transport,
body: SubmitUgcRequest,
): Promise<UgcSubmission> =>
t.request<UgcSubmission>('POST', '/sdk/v1/ugc', body),
triggerScenario: (
t: Transport,
body: TriggerScenarioRequest,
): Promise<TriggerScenarioResponse> =>
t.request<TriggerScenarioResponse>('POST', '/sdk/v1/scenarios/trigger', body),
updateScenarioCounter: (
t: Transport,
body: UpdateScenarioCounterRequest,
): Promise<UpdateScenarioCounterResponse> =>
t.request<UpdateScenarioCounterResponse>('POST', '/sdk/v1/scenarios/counter', body),
updateStorage: (
t: Transport,
body: UpdateStorageRequest,
): Promise<void> =>
t.request<void>('PUT', '/sdk/v1/storage', body),
};
+24
View File
@@ -0,0 +1,24 @@
// Code generated by apigen. DO NOT EDIT.
export interface LoginViaDeviceRequest {
"deviceId"?: string;
"key"?: string;
"language"?: string;
"nickname"?: string;
"region"?: string;
}
export interface LoginViaDeviceResponse {
"accessToken"?: string;
"refreshToken"?: string;
}
export interface RefreshAccessTokenRequest {
"refreshToken"?: string;
}
export interface RefreshAccessTokenResponse {
"accessToken"?: string;
"refreshToken"?: string;
}
+71
View File
@@ -0,0 +1,71 @@
// Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan } from './common.js';
export interface AddBattlePassXpRequest {
"amount"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"source"?: string;
}
export interface AddBattlePassXpResponse {
"level"?: number;
"leveledUp"?: boolean;
"maxLevel"?: boolean;
"plan"?: ExecutionPlan;
"xp"?: number;
}
export interface BattlePassReward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
}
export interface ClaimBattlePassRewardRequest {
"level"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"track"?: string;
}
export interface ClaimBattlePassRewardResponse {
"alreadyClaimed"?: boolean;
"error"?: string;
"granted"?: BattlePassReward[];
"success"?: boolean;
}
export interface ClaimedTier {
"level"?: number;
"track"?: string;
}
export interface GetBattlePassProgressRequest {
"nodeId"?: string;
"scenarioId"?: string;
}
export interface GetBattlePassProgressResponse {
"claimedTiers"?: ClaimedTier[];
"level"?: number;
"premiumOwned"?: boolean;
"xp"?: number;
}
export interface PurchaseBattlePassPremiumRequest {
"idempotencyKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
}
export interface PurchaseBattlePassPremiumResponse {
"error"?: string;
"plan"?: ExecutionPlan;
"success"?: boolean;
}
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
export interface CatalogItem {
"name"?: string;
"properties"?: { [key: string]: unknown };
"slug"?: string;
"tags"?: string[];
}
export interface ListCatalogItemsResponse {
"items"?: CatalogItem[];
}
+44
View File
@@ -0,0 +1,44 @@
// Code generated by apigen. DO NOT EDIT.
export interface BoundaryNode {
"callbackUrl"?: string;
"enforcement"?: "client" | "server";
"enteredAt"?: string;
"nodeId"?: string;
"sourceHandle"?: string;
"sourceNodeId"?: string;
"waitDeadline"?: string;
}
export interface ErrorResponse {
"code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
"error"?: string;
"requestId"?: string;
}
export interface ExecutionPlan {
"boundaryNodes"?: BoundaryNode[];
"context"?: { [key: string]: unknown };
"edges"?: PlanEdge[];
"nodes"?: ExecutionPlanNode[];
"planId"?: string;
"runId"?: string;
"scenarioId"?: string;
"startNodeId"?: string;
"userId"?: string;
}
export interface ExecutionPlanNode {
"data"?: { [key: string]: unknown };
"id"?: string;
"type"?: string;
}
export interface PlanEdge {
"id"?: string;
"source"?: string;
"sourceHandle"?: string;
"target"?: string;
"targetHandle"?: string;
}
+15
View File
@@ -0,0 +1,15 @@
// Code generated by apigen. DO NOT EDIT.
export type RudderErrorCode = "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
export const RudderErrorCodes = {
earlyCompletion: "early_completion",
forbidden: "forbidden",
levelNotReached: "level_not_reached",
nodeNotActive: "node_not_active",
objectivesIncomplete: "objectives_incomplete",
runExpired: "run_expired",
runNotActive: "run_not_active",
scenarioNotActive: "scenario_not_active",
unknownRun: "unknown_run",
} as const;
+17
View File
@@ -0,0 +1,17 @@
// Code generated by apigen. DO NOT EDIT.
export * from './auth.js';
export * from './battlepass.js';
export * from './catalog.js';
export * from './common.js';
export * from './inventory.js';
export * from './leaderboards.js';
export * from './player.js';
export * from './quests.js';
export * from './remote-config.js';
export * from './scenarios.js';
export * from './storage.js';
export * from './stores.js';
export * from './ugc.js';
export * from './api.js';
export * from './errors.js';
+14
View File
@@ -0,0 +1,14 @@
// Code generated by apigen. DO NOT EDIT.
export interface GetInventoryResponse {
"items"?: PlayerInventoryItem[];
}
export interface PlayerInventoryItem {
"amount"?: number;
"nameOverride"?: string;
"propertiesOverride"?: { [key: string]: unknown };
"slug"?: string;
"updatedAt"?: string;
}
+19
View File
@@ -0,0 +1,19 @@
// Code generated by apigen. DO NOT EDIT.
export interface GetRankingResponse {
"entries"?: RankEntry[];
"total"?: number;
}
export interface RankEntry {
"playerId"?: string;
"playerName"?: string;
"rank"?: number;
"score"?: number;
}
export interface SubmitScoreRequest {
"score"?: number;
"slug"?: string;
}
+21
View File
@@ -0,0 +1,21 @@
// Code generated by apigen. DO NOT EDIT.
export interface Player {
"createdAt"?: string;
"id"?: string;
"language"?: string;
"nickname"?: string;
"projectId"?: string;
"region"?: string;
}
export interface PlayerProfile {
"player"?: Player;
"wallets"?: Wallet[];
}
export interface Wallet {
"balance"?: number;
"currency"?: string;
}
+42
View File
@@ -0,0 +1,42 @@
// Code generated by apigen. DO NOT EDIT.
export interface ClaimQuestRequest {
"questId"?: string;
}
export interface ClaimQuestResponse {
"alreadyClaimed"?: boolean;
"error"?: string;
"granted"?: QuestReward[];
"success"?: boolean;
}
export interface ListQuestsRequest {
}
export interface ListQuestsResponse {
"quests"?: Quest[];
}
export interface Quest {
"id"?: string;
"name"?: string;
"objectives"?: QuestObjectiveProgress[];
"rewards"?: QuestReward[];
"status"?: string;
}
export interface QuestObjectiveProgress {
"completed"?: boolean;
"current"?: number;
"metric"?: string;
"objectiveId"?: string;
"target"?: number;
}
export interface QuestReward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
}
+19
View File
@@ -0,0 +1,19 @@
// Code generated by apigen. DO NOT EDIT.
export interface ListRemoteConfigsResponse {
"configs"?: { [key: string]: RemoteConfig };
}
export interface RemoteConfig {
"active"?: boolean;
"createdAt"?: string;
"description"?: string;
"environment"?: string;
"id"?: string;
"key"?: string;
"projectId"?: string;
"updatedAt"?: string;
"value"?: string;
"valueType"?: string;
}
+46
View File
@@ -0,0 +1,46 @@
// Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan } from './common.js';
export interface GetScenarioRunRequest {
"runId"?: string;
}
export interface GetScenarioRunResponse {
"plan"?: ExecutionPlan;
"runId"?: string;
"status"?: string;
}
export interface HandleScenarioCallbackRequest {
"handle"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
}
export interface HandleScenarioCallbackResponse {
"plan"?: ExecutionPlan;
}
export interface TriggerScenarioRequest {
"event"?: string;
}
export interface TriggerScenarioResponse {
"plans"?: ExecutionPlan[];
}
export interface UpdateScenarioCounterRequest {
"amount"?: number;
"counterKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
}
export interface UpdateScenarioCounterResponse {
"completed"?: boolean;
"plan"?: ExecutionPlan;
}
+18
View File
@@ -0,0 +1,18 @@
// Code generated by apigen. DO NOT EDIT.
export interface GetStorageResponse {
"items"?: StorageItem[];
"nextCursor"?: string;
}
export interface StorageItem {
"data"?: string;
"id"?: string;
"type"?: string;
}
export interface UpdateStorageRequest {
"idempotencyKey"?: string;
"items"?: StorageItem[];
}
+54
View File
@@ -0,0 +1,54 @@
// Code generated by apigen. DO NOT EDIT.
export interface ListStoresResponse {
"stores"?: Store[];
"total"?: number;
}
export interface Offer {
"contents"?: OfferContent[];
"createdAt"?: string;
"id"?: string;
"maxPurchases"?: number;
"name"?: string;
"price"?: OfferPrice;
"updatedAt"?: string;
}
export interface OfferContent {
"amount"?: number;
"itemId"?: string;
}
export interface OfferPrice {
"amount"?: number;
"currency"?: string;
}
export interface PurchaseOfferRequest {
"idempotencyKey"?: string;
"offerId"?: string;
"storeSlug"?: string;
}
export interface PurchaseOfferResponse {
"error"?: string;
"purchaseId"?: string;
"success"?: boolean;
}
export interface Store {
"createdAt"?: string;
"data"?: { [key: string]: unknown };
"description"?: string;
"environment"?: string;
"id"?: string;
"name"?: string;
"offers"?: Offer[];
"projectId"?: string;
"scenarioId"?: string;
"slug"?: string;
"status"?: string;
"updatedAt"?: string;
}
+47
View File
@@ -0,0 +1,47 @@
// Code generated by apigen. DO NOT EDIT.
export interface DeleteUgcResponse {
"success"?: boolean;
}
export interface GetDownloadUrlResponse {
"downloadUrl"?: string;
}
export interface GetUploadUrlResponse {
"fileKey"?: string;
"uploadUrl"?: string;
}
export interface ListUgcResponse {
"items"?: UgcSubmission[];
"nextCursor"?: string;
}
export interface SubmitUgcRequest {
"description"?: string;
"fileKey"?: string;
"fileSize"?: number;
"metadata"?: { [key: string]: unknown };
"name"?: string;
}
export interface SubmittedBy {
"userId"?: string;
"username"?: string;
}
export interface UgcSubmission {
"description"?: string;
"fileSize"?: number;
"fileUrl"?: string;
"id"?: string;
"metadata"?: { [key: string]: unknown };
"name"?: string;
"reviewedAt"?: string;
"reviewedBy"?: string;
"status"?: string;
"submittedAt"?: string;
"submittedBy"?: SubmittedBy;
}
+103
View File
@@ -0,0 +1,103 @@
// @rudder/web-sdk — LiveOps Web SDK for browser games
// Client
export { RudderClient } from './client/RudderClient.js';
export type { RudderClientOptions } from './client/RudderClientOptions.js';
export {
RudderError,
RudderNetworkError,
RudderHttpError,
RudderAuthError,
SDK_ERROR_INVALID_OPTIONS,
} from './client/RudderError.js';
export type { RudderErrorCodeLike } from './client/RudderError.js';
// Token management
export type { TokenStore } from './token/TokenStore.js';
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
// Services — types only; instances live on the client
// (client.auth, client.leaderboards, client.battlePass, client.quests).
export type {
AuthService,
AuthState,
AuthStateListener,
LoginWithDeviceOptions,
} from './auth/AuthService.js';
export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js';
export type { BattlePassService } from './battlepass/BattlePassService.js';
export type { QuestsService } from './quests/QuestsService.js';
// Observable state primitives
export { SyncedState } from './state/SyncedState.js';
export type {
SyncedSnapshot,
SyncedStatus,
SyncedListener,
} from './state/SyncedState.js';
export { RemoteConfigState } from './state/RemoteConfigState.js';
export type { RemoteConfigShape } from './state/RemoteConfigState.js';
export { ShopHandle, OfferHandle } from './state/shops.js';
export type { BuyOptions } from './state/shops.js';
export type { InventoryItem } from './state/inventory.js';
// Domains — types only; instances live on the client
// (client.player, client.stores, client.remoteConfig, …).
export type { PlayerDomain } from './domains/PlayerDomain.js';
export type { CatalogDomain } from './domains/CatalogDomain.js';
export type { InventoryDomain } from './domains/InventoryDomain.js';
export type { ConfigDomain } from './domains/ConfigDomain.js';
export type { StorageDomain } from './domains/StorageDomain.js';
export type { StoresDomain } from './domains/StoresDomain.js';
// Effects
export type {
ConfigChangedEffect,
Effects,
EffectUnsubscribe,
LeaderboardEffect,
NotificationEffect,
StoreOfferEffect,
WaitEffect,
ScenarioCompletedEffect,
ScenarioFailedEffect,
QuestEffect,
BattlePassEffect,
BattlePassLevelEffect,
} from './effects/EffectsCenter.js';
// Generated wire types — the shapes returned by the observable state and
// handles, so consumers can name them instead of re-declaring mirrors.
export type { Player, PlayerProfile, Wallet } from './generated/player.js';
export type { CatalogItem } from './generated/catalog.js';
export type { PlayerInventoryItem } from './generated/inventory.js';
export type {
Store,
Offer,
OfferContent,
OfferPrice,
PurchaseOfferResponse,
} from './generated/stores.js';
export type { RankEntry } from './generated/leaderboards.js';
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
export type { RemoteConfig } from './generated/remote-config.js';
export type {
Quest,
QuestObjectiveProgress,
QuestReward,
ClaimQuestResponse,
} from './generated/quests.js';
export type {
BattlePassReward,
ClaimedTier,
AddBattlePassXpRequest,
AddBattlePassXpResponse,
ClaimBattlePassRewardRequest,
ClaimBattlePassRewardResponse,
PurchaseBattlePassPremiumRequest,
PurchaseBattlePassPremiumResponse,
GetBattlePassProgressRequest,
GetBattlePassProgressResponse,
} from './generated/battlepass.js';
export type { RudderErrorCode } from './generated/errors.js';
export { RudderErrorCodes } from './generated/errors.js';
+52
View File
@@ -0,0 +1,52 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { RankEntry } from '../generated/leaderboards.js';
/**
* A cached handle for a specific leaderboard.
*
* Created by `LeaderboardsService.findBySlug()` — subsequent calls with
* the same slug return the same handle, avoiding redundant fetches.
*/
export class LeaderboardHandle {
private entries: RankEntry[] = [];
constructor(
public readonly slug: string,
private readonly ctx: RudderContext,
) {}
getEntries(): readonly RankEntry[] {
return this.entries;
}
async submit(score: number): Promise<void> {
return api.submitScore(this.ctx, { slug: this.slug }, { slug: this.slug, score });
}
async list(limit = 100): Promise<readonly RankEntry[]> {
const response = await api.getRanking(
this.ctx,
{ slug: this.slug },
{ limit: limit > 0 ? limit : undefined },
);
this.entries = response.entries ?? [];
return this.entries;
}
}
export class LeaderboardsService {
private readonly cache = new Map<string, LeaderboardHandle>();
constructor(private readonly ctx: RudderContext) {}
/** Returns a cached handle for the given leaderboard slug. */
findBySlug(slug: string): LeaderboardHandle {
let handle = this.cache.get(slug);
if (!handle) {
handle = new LeaderboardHandle(slug, this.ctx);
this.cache.set(slug, handle);
}
return handle;
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* QuestsService — call-and-response access to the global quest endpoints.
*
* These are the player's global quests (list + claim), distinct from scenario
* quest nodes (which advance via {@link QuestSession}). Global quests have no
* sync-revision key, so this is a plain service; re-list after a claim.
*/
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { Quest, ClaimQuestResponse } from '../generated/quests.js';
export class QuestsService {
constructor(private readonly ctx: RudderContext) {}
/** Lists the player's quests with per-objective progress and rewards. */
async list(): Promise<Quest[]> {
const response = await api.listQuests(this.ctx, {});
return response.quests ?? [];
}
/** Claims a completed quest's rewards (idempotent server-side). */
claim(questId: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questId });
}
}
+593
View File
@@ -0,0 +1,593 @@
import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape, RemoteConfigState } from '../state/RemoteConfigState.js';
import type { ShopHandle } from '../state/shops.js';
import { api } from '../generated/api.js';
import type { TriggerScenarioResponse } from '../generated/scenarios.js';
import type { ExecutionPlan } from '../generated/common.js';
import type { PlanStateStore } from './engine/IndexedDbPlanStore.js';
/**
* The subset of domains the scenario runtime drives: it invalidates the player
* and inventory after server mutations, patches remote config for override
* nodes, and resolves stores for store nodes.
*/
export interface ScenarioDomains {
readonly player: { invalidate(): void };
readonly inventory: { invalidate(): void };
readonly config: RemoteConfigState;
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
}
import {
RudderNetworkError,
RudderHttpError,
} from '../client/RudderError.js';
import {
findNode,
matchingEdges,
matchingBoundaryNodes,
completedHandleKey,
durationToMs,
} from './engine/DagWalker.js';
import { RuntimeRun, PlanRun, type ActiveNodeState, type ScenarioRunFailedEvent } from './engine/types.js';
import {
ScenarioNodeContext,
NotificationSession,
WaitSession,
StoreSession,
LeaderboardSession,
QuestSession,
BattlePassSession,
BattlePassLevelSession,
} from './engine/sessions.js';
import type { BattlePassService } from '../battlepass/BattlePassService.js';
/**
* Error thrown when a boundary HTTP call fails with a transient error
* (network failure or server 5xx). The caller should NOT advance the run;
* the node stays active and the handle stays pending for retry on reconnect.
*/
class TransientBoundaryError extends Error {
constructor(public readonly inner: unknown) {
super('Transient boundary error');
this.name = 'TransientBoundaryError';
}
}
/** Returns true for network / 5xx errors that may succeed on retry. */
function isTransientHttpError(err: unknown): boolean {
if (err instanceof RudderNetworkError) return true;
if (err instanceof RudderHttpError && err.status >= 500) return true;
return false;
}
export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
private readonly runs = new Map<string, RuntimeRun>();
private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly ctx: RudderContext,
private readonly domains: ScenarioDomains,
private readonly battlePass: BattlePassService,
private readonly planStore: PlanStateStore | null,
) {}
// ---- Public Events (subscribe to receive node dispatches) ----
onNotification?: (session: NotificationSession) => void;
onWait?: (session: WaitSession) => void;
onStore?: (session: StoreSession) => void;
onLeaderboard?: (session: LeaderboardSession) => void;
onRunCompleted?: (run: PlanRun) => void;
onRunFailed?: (event: ScenarioRunFailedEvent) => void;
onCompleted?: () => void;
get isRunning(): boolean {
return this.runs.size > 0;
}
get activeRuns(): readonly PlanRun[] {
return [...this.runs.values()].map((r) => this.toPlanRun(r));
}
// ---- Public API ----
/** Triggers scenarios by event name. Returns all started plans. */
async send(eventName: string): Promise<TriggerScenarioResponse> {
const response = await api.triggerScenario(this.ctx, { event: eventName });
this.domains.player.invalidate();
this.domains.inventory.invalidate();
this.startPlans(response.plans ?? []);
return response;
}
/** Completes the current active node with the given handle. */
async respond(handle: string): Promise<void> {
for (const run of this.runs.values()) {
for (const nodeId of run.activeNodes.keys()) {
await this.completeNodeAsync(run.runId, nodeId, handle);
return;
}
}
}
/**
* Restores persisted scenario state from IndexedDB.
* Call once after constructing the client.
*/
async restore(): Promise<void> {
const store = this.planStore;
if (!store) return;
try {
await store.load();
const json = store.state;
if (!json) return;
const persisted = JSON.parse(json) as { runs: Array<{
runId: string;
plan: ExecutionPlan;
activeNodes: ActiveNodeState[];
completedHandles: string[];
}> };
for (const saved of persisted.runs ?? []) {
let plan = saved.plan;
let activeNodes = saved.activeNodes;
let completedHandles = saved.completedHandles;
try {
const response = await api.getScenarioRun(this.ctx, { runId: saved.runId });
if (response?.status === 'unknown_run' || response?.status === 'expired') {
continue;
}
if (response?.plan) {
plan = response.plan;
activeNodes = [];
completedHandles = [];
}
} catch {
// Network/server error — fall back to persisted local state.
}
const run = new RuntimeRun(saved.runId, plan);
for (const handle of completedHandles ?? []) {
run.completedHandles.add(handle);
}
this.runs.set(run.runId, run);
if (activeNodes.length > 0) {
for (const nodeState of activeNodes) {
run.activeNodes.set(nodeState.nodeId, nodeState);
this.dispatchActiveNode(run, nodeState, true);
}
} else if (plan?.nodes?.length) {
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0];
if (startNode?.id) {
this.activateNode(run, startNode.id);
}
}
}
await this.persist();
} catch {
// Corrupted state — clear and continue.
this.clear();
}
}
/** Clears all active runs and persisted state. */
clear(): void {
for (const timer of this.waitTimers.values()) {
clearTimeout(timer);
}
this.waitTimers.clear();
this.runs.clear();
this.persist().catch(() => {});
}
// ---- Internal: Plan lifecycle ----
private startPlans(plans: ExecutionPlan[]): void {
for (const plan of plans ?? []) {
this.startPlan(plan);
}
}
private startPlan(plan: ExecutionPlan): void {
if (!plan.nodes?.length) return;
if (plan.boundaryNodes?.length && !plan.runId) {
throw new Error('ExecutionPlan has boundaryNodes but missing runId');
}
// Dedup: server returned a plan with a runId already active — skip without
// restarting wait timers or re-dispatching node sessions.
if (plan.runId && this.runs.has(plan.runId)) return;
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0];
if (!startNode?.id) return;
const runId = plan.runId ?? crypto.randomUUID().replace(/-/g, '');
const run = new RuntimeRun(runId, plan);
this.runs.set(run.runId, run);
this.activateNode(run, startNode.id);
this.persist().catch(() => {});
}
private activateNode(
run: RuntimeRun,
nodeId: string,
restoredState?: ActiveNodeState,
): void {
const node = findNode(run.plan, nodeId);
if (!node?.id) return;
const state = restoredState ?? { nodeId };
run.activeNodes.set(nodeId, state);
this.dispatchActiveNode(run, state, restoredState !== undefined);
}
// ---- Internal: Node Dispatch ----
private dispatchActiveNode(
run: RuntimeRun,
state: ActiveNodeState,
restored: boolean,
): void {
const node = findNode(run.plan, state.nodeId);
if (!node) {
run.activeNodes.delete(state.nodeId);
this.checkRunCompleted(run);
return;
}
const ctx = new ScenarioNodeContext(
this.toPlanRun(run),
node,
(rid, nid, h) => this.completeNodeInternal(rid, nid, h, true),
(rid, nid, ck, amt) => this.updateProgressInternal(rid, nid, ck, amt),
);
switch (node.type) {
case 'wait':
this.dispatchWait(run, state, ctx);
break;
case 'remote_config_override':
this.dispatchRemoteConfigOverride(run, state, ctx);
break;
case 'notification':
{
const session = new NotificationSession(ctx);
this.ctx.effects.emitNotification(session);
this.onNotification?.(session);
}
break;
case 'store':
{
const session = new StoreSession(ctx, this.domains.stores);
this.ctx.effects.emitStoreOffer(session);
this.onStore?.(session);
}
break;
case 'leaderboard':
{
const session = new LeaderboardSession(ctx);
this.ctx.effects.emitLeaderboard(session);
this.onLeaderboard?.(session);
}
break;
case 'quest':
this.ctx.effects.emitQuest(new QuestSession(ctx));
break;
case 'battlepass':
this.ctx.effects.emitBattlePass(new BattlePassSession(ctx, this.battlePass));
break;
case 'battlepass_level':
this.ctx.effects.emitBattlePassLevel(new BattlePassLevelSession(ctx));
break;
default:
// Unsupported node type — fail the run (surfaced via onScenarioFailed)
// instead of leaving it stalled on a node no handler will complete.
console.warn(
`[Rudder] Unsupported scenario node type '${node.type}' (${node.id})`,
);
this.failRun(
run,
state.nodeId,
new Error(`Unsupported scenario node type '${node.type}'`),
);
break;
}
}
private dispatchWait(
run: RuntimeRun,
state: ActiveNodeState,
ctx: ScenarioNodeContext,
): void {
// Prefer server-provided waitDeadline from the plan boundary over local calculation.
// The server stamps waitDeadline on server-enforced wait boundaries (see StampBoundaries).
if (!state.waitDeadlineUtc) {
const boundary = (run.plan.boundaryNodes ?? []).find(
b => b.sourceNodeId === state.nodeId && b.waitDeadline,
);
if (boundary?.waitDeadline) {
state.waitDeadlineUtc = boundary.waitDeadline;
} else {
const data = ctx.data;
const duration = (data.duration as number) ?? 0;
const unit = (data.unit as string) ?? 'seconds';
const ms = durationToMs(duration, unit);
state.waitDeadlineUtc = new Date(Date.now() + ms).toISOString();
}
this.persist().catch(() => {});
}
const deadline = new Date(state.waitDeadlineUtc).getTime();
const session = new WaitSession(ctx, new Date(deadline));
this.onWait?.(session);
this.ctx.effects.emitWait(session);
const remaining = deadline - Date.now();
if (remaining <= 0) {
this.completeNode(run.runId, state.nodeId, 'onComplete');
} else {
const timerKey = `${run.runId}:${state.nodeId}`;
clearTimeout(this.waitTimers.get(timerKey));
this.waitTimers.set(
timerKey,
setTimeout(() => {
this.completeNode(run.runId, state.nodeId, 'onComplete');
this.waitTimers.delete(timerKey);
}, remaining),
);
}
}
private dispatchRemoteConfigOverride(
run: RuntimeRun,
state: ActiveNodeState,
ctx: ScenarioNodeContext,
): void {
const patches = ctx.data.patches as Array<{
path?: string;
valueType?: string;
value?: string;
}> | undefined;
if (patches) {
for (const patch of patches) {
if (patch.path) {
this.domains.config.applyOverride(
patch.path,
patch.value ?? '',
patch.valueType ?? 'json',
);
this.ctx.effects.emitConfigChanged(patch.path);
}
}
}
this.completeNode(run.runId, state.nodeId, 'output');
}
// ---- Internal: Node completion ----
/** Synchronous fire-and-forget completion. */
private completeNode(runId: string, nodeId: string, handle: string): void {
this.completeNodeInternal(runId, nodeId, handle, true).catch(() => {});
}
/** Async completion — called by sessions. */
async completeNodeAsync(
runId: string,
nodeId: string,
handle: string,
): Promise<void> {
return this.completeNodeInternal(runId, nodeId, handle, true);
}
private async completeNodeInternal(
runId: string,
nodeId: string,
handle: string,
continueOnBoundary: boolean,
): Promise<void> {
const run = this.runs.get(runId);
if (!run) return;
const key = completedHandleKey(nodeId, handle);
if (run.completedHandles.has(key)) return; // idempotent
// Mark this transition as in flight so that a transiently-empty
// activeNodes (e.g. between this node and an auto-completing successor)
// does not cause the run to be reported complete prematurely.
run.pendingTransitions++;
try {
let boundaryContinued = false;
if (continueOnBoundary) {
try {
boundaryContinued = await this.continueBoundary(run, nodeId, handle);
} catch (err: unknown) {
if (err instanceof TransientBoundaryError) {
// Transient error — don't advance the run.
// Node stays active, handle stays pending for retry on reconnect.
run.pendingTransitions--;
await this.persist();
return;
}
throw err; // terminal — handled by outer catch
}
}
// Only now — after the server has confirmed — mark the handle and node.
run.completedHandles.add(key);
run.activeNodes.delete(nodeId);
await this.persist();
if (!boundaryContinued) {
for (const edge of matchingEdges(run.plan, nodeId, handle)) {
if (edge.target) {
this.activateNode(run, edge.target);
}
}
}
run.pendingTransitions--;
this.checkRunCompleted(run);
await this.persist();
} catch (err) {
run.pendingTransitions--;
this.failRun(
run,
nodeId,
err instanceof Error ? err : new Error(String(err)),
);
}
}
private async continueBoundary(
run: RuntimeRun,
nodeId: string,
handle: string,
): Promise<boolean> {
const boundaries = [...matchingBoundaryNodes(run.plan, nodeId, handle)];
if (boundaries.length === 0) return false;
for (const boundary of boundaries) {
try {
const response = await api.handleScenarioCallback(this.ctx, {
scenarioId: run.plan.scenarioId,
nodeId: boundary.sourceNodeId,
handle: boundary.sourceHandle,
runId: run.runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (response?.plan) {
this.startPlan(response.plan);
}
} catch (err: unknown) {
// Boundary call failed — try to reconcile with server.
let reconciled = false;
try {
const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId });
if (reconcile?.status === 'unknown_run' || reconcile?.status === 'expired') {
this.runs.delete(run.runId);
await this.persist();
reconciled = true;
} else if (reconcile?.plan) {
const newRun = new RuntimeRun(run.runId, reconcile.plan);
this.runs.set(run.runId, newRun);
const startNode = findNode(reconcile.plan, reconcile.plan.startNodeId) ?? reconcile.plan.nodes?.[0];
if (startNode?.id) {
this.activateNode(newRun, startNode.id);
}
reconciled = true;
}
} catch {
// Reconciliation also failed.
}
if (!reconciled) {
// Reconcile did not resolve — distinguish transient from terminal.
if (isTransientHttpError(err)) {
throw new TransientBoundaryError(err);
}
// Terminal error (typed error code from the server) — let the caller failRun.
throw err;
}
// If reconciled, the boundary was handled (run corrected or removed).
// Fall through to continue to the next boundary.
}
}
return true;
}
// ---- Internal: Counter progress ----
async updateProgressInternal(
runId: string,
nodeId: string,
counterKey: string,
amount: number,
): Promise<void> {
const run = this.runs.get(runId);
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioId: run?.plan.scenarioId ?? '',
nodeId,
counterKey,
amount,
runId,
});
// The server reports objective completion; it no longer returns a plan from the
// counter endpoint. On completion, cross the quest's onComplete boundary (which
// grants rewards + advances the run) — idempotent if the consumer also calls complete().
if (response?.completed) {
await this.completeNodeInternal(runId, nodeId, 'onComplete', true);
}
} catch {
// Counter update failure does not fail the run.
}
}
// ---- Internal: Run lifecycle ----
private checkRunCompleted(run: RuntimeRun): void {
if (run.activeNodes.size > 0) return;
// A node transition is still settling (e.g. an auto-completing
// remote_config_override node is about to activate its successor).
// Wait for it to finish before declaring the run complete.
if (run.pendingTransitions > 0) return;
if (!this.runs.has(run.runId)) return; // already completed/removed
this.runs.delete(run.runId);
const planRun = this.toPlanRun(run);
this.onRunCompleted?.(planRun);
this.onCompleted?.();
this.ctx.effects.emitScenarioCompleted(planRun);
this.persist().catch(() => {});
}
private failRun(
run: RuntimeRun,
nodeId: string,
error: Error,
): void {
console.warn(
`[Rudder] Scenario run ${run.runId} failed at node ${nodeId}: ${error.message}`,
);
this.runs.delete(run.runId);
const event: ScenarioRunFailedEvent = { run: this.toPlanRun(run), nodeId, error };
this.onRunFailed?.(event);
this.ctx.effects.emitScenarioFailed(event);
this.persist().catch(() => {});
}
// ---- Internal: Helpers ----
private toPlanRun(run: RuntimeRun): PlanRun {
return new PlanRun(
run.runId,
run.plan.planId ?? '',
run.plan.scenarioId ?? '',
run.plan.userId ?? '',
[...run.activeNodes.keys()],
run.plan,
);
}
private async persist(): Promise<void> {
const store = this.planStore;
if (!store) return;
const state = JSON.stringify({
runs: [...this.runs.values()].map((run) => ({
runId: run.runId,
plan: run.plan,
activeNodes: [...run.activeNodes.values()],
completedHandles: [...run.completedHandles],
})),
});
store.state = state;
try {
await store.save(state);
} catch {
// Best-effort persistence.
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../generated/common.js';
/** Finds a node by ID within a plan (linear scan of the nodes array). */
export function findNode(
plan: ExecutionPlan,
nodeId: string | undefined,
): ExecutionPlanNode | undefined {
if (!nodeId || !plan.nodes) return undefined;
return plan.nodes.find((n) => n.id === nodeId);
}
/** Yields all edges whose source node and sourceHandle match. */
export function* matchingEdges(
plan: ExecutionPlan,
sourceNodeId: string,
sourceHandle: string,
): Generator<PlanEdge> {
for (const edge of plan.edges ?? []) {
if (
edge.source === sourceNodeId &&
(edge.sourceHandle ?? '') === (sourceHandle ?? '')
) {
yield edge;
}
}
}
/** Yields all boundary nodes matching the completed node + handle. */
export function* matchingBoundaryNodes(
plan: ExecutionPlan,
sourceNodeId: string,
sourceHandle: string,
): Generator<BoundaryNode> {
for (const boundary of plan.boundaryNodes ?? []) {
if (
boundary.sourceNodeId === sourceNodeId &&
(boundary.sourceHandle ?? '') === (sourceHandle ?? '')
) {
yield boundary;
}
}
}
/** Returns a deduplication key for completed node+handle pairs. */
export function completedHandleKey(nodeId: string, handle: string): string {
return `${nodeId}:${handle ?? ''}`;
}
/** Converts wait duration+unit to milliseconds. Unit required; defaults to seconds when unit is unrecognized. */
export function durationToMs(duration: number, unit: string): number {
switch ((unit ?? 'seconds').toLowerCase()) {
case 'days':
case 'day':
case 'd':
return duration * 86_400_000;
case 'hours':
case 'hour':
case 'hr':
case 'h':
return duration * 3_600_000;
case 'minutes':
case 'minute':
case 'min':
case 'm':
return duration * 60_000;
case 'seconds':
case 'second':
case 'sec':
case 's':
return duration * 1000;
default:
return duration * 1000; // default: seconds
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* IndexedDB-backed plan state store for persisting scenario execution state.
*
* The C# SDK uses a synchronous `IPlanStateStore { string State { get; set; } }`.
* IndexedDB is inherently async, so we provide an async `load()` / `save()` API
* alongside a synchronous `state` property for immediate reads after load.
*/
export interface PlanStateStore {
/** Current serialized state (available after `load()` or `save()`). */
state: string | null;
/** Loads persisted state from IndexedDB. Call once on SDK initialization. */
load(): Promise<void>;
/** Persists the current state to IndexedDB. */
save(state: string | null): Promise<void>;
}
const DB_NAME = 'RudderPlanState';
const STORE_NAME = 'state';
const KEY = 'active_runs';
const DB_VERSION = 1;
export function createIndexedDbPlanStateStore(): PlanStateStore {
let dbPromise: Promise<IDBDatabase> | null = null;
let cachedState: string | null = null;
function getDb(): Promise<IDBDatabase> {
if (!dbPromise) {
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return dbPromise;
}
return {
get state(): string | null {
return cachedState;
},
set state(value: string | null) {
cachedState = value;
// Best-effort async write — does not block the setter.
getDb()
.then((db) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
if (value === null) {
tx.objectStore(STORE_NAME).delete(KEY);
} else {
tx.objectStore(STORE_NAME).put(value, KEY);
}
})
.catch(() => {
// IndexedDB write failed — state is still in memory.
});
},
async load(): Promise<void> {
const db = await getDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(KEY);
req.onsuccess = () => {
cachedState = (req.result as string) ?? null;
resolve();
};
req.onerror = () => reject(req.error);
});
},
async save(state: string | null): Promise<void> {
cachedState = state;
const db = await getDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const req =
state === null
? tx.objectStore(STORE_NAME).delete(KEY)
: tx.objectStore(STORE_NAME).put(state, KEY);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
};
}
+342
View File
@@ -0,0 +1,342 @@
import type { ExecutionPlanNode } from '../../generated/common.js';
import type { PlanRun } from './types.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../../state/shops.js';
import type { PurchaseOfferResponse } from '../../generated/stores.js';
import type { BattlePassService } from '../../battlepass/BattlePassService.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
GetBattlePassProgressResponse,
PurchaseBattlePassPremiumResponse,
} from '../../generated/battlepass.js';
/** Resolves a store handle by slug — satisfied by the stores domain. */
interface StoreResolver {
getBySlug(slug: string): Promise<ShopHandle>;
}
// ---- ScenarioNodeContext ----
/**
* Context object passed to each session.
* Wraps the current run, plan node, and raw node data.
* The session calls `complete(handle)` to advance the DAG.
*/
export class ScenarioNodeContext {
constructor(
public readonly run: PlanRun,
public readonly node: ExecutionPlanNode,
private readonly onComplete: (runId: string, nodeId: string, handle: string) => Promise<void>,
private readonly onProgress: (runId: string, nodeId: string, counterKey: string, amount: number) => Promise<void>,
) {}
/** Extracts a typed value from node data. */
get<T>(key: string, defaultValue: T): T {
const data = this.node.data as Record<string, unknown> | undefined;
const value = data?.[key];
if (value === undefined || value === null) return defaultValue;
return value as unknown as T;
}
/** Full node data as a typed record. */
get data(): Record<string, unknown> {
return (this.node.data as Record<string, unknown>) ?? {};
}
/** Completes the current node with the given output handle. */
async complete(handle: string): Promise<void> {
await this.onComplete(this.run.runId, this.node.id!, handle);
}
/** Updates counter progress for server-managed scenario nodes. */
async updateProgress(counterKey: string, amount: number): Promise<void> {
await this.onProgress(this.run.runId, this.node.id!, counterKey, amount);
}
}
// ---- NotificationSession ----
export class NotificationSession {
public readonly title: string;
public readonly message: string;
constructor(private readonly context: ScenarioNodeContext) {
this.title = context.get('title', '');
this.message = context.get('message', '');
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async complete(): Promise<void> {
await this.context.complete('output');
}
}
// ---- WaitSession ----
export class WaitSession {
constructor(
private readonly context: ScenarioNodeContext,
public readonly deadlineUtc: Date,
) {}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
}
// ---- StoreSession ----
export class StoreSession {
private resolved = false;
constructor(
private readonly context: ScenarioNodeContext,
private readonly stores: StoreResolver,
) {}
get isResolved(): boolean {
return this.resolved;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async getStore(): Promise<ShopHandle> {
return this.stores.getBySlug(this.get('storeSlug', ''));
}
async buy(
offer: OfferHandle,
options?: BuyOptions,
): Promise<PurchaseOfferResponse> {
if (this.resolved) {
return { success: false, error: 'store session already resolved' };
}
const purchase = await offer.buy(options);
if (purchase.success) {
this.resolved = true;
await this.context.complete('onPurchase');
}
return purchase;
}
async decline(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onDecline');
}
}
// ---- LeaderboardSession ----
export class LeaderboardSession {
private resolved = false;
constructor(private readonly context: ScenarioNodeContext) {}
get isResolved(): boolean {
return this.resolved;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async end(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onEnd');
}
async rewardClaimed(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onRewardClaimed');
}
}
// ---- QuestSession ----
/**
* A scenario quest node. The game reports objective progress; the server
* auto-completes the node (crossing `onComplete`, which grants rewards and
* advances the run) once every objective is satisfied.
*/
export class QuestSession {
constructor(private readonly context: ScenarioNodeContext) {}
get name(): string {
return this.context.get('name', '');
}
get objectives(): Array<Record<string, unknown>> {
return this.context.get('objectives', [] as Array<Record<string, unknown>>);
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
/**
* Reports progress toward an objective. When the reported counter completes
* every objective, the server signals completion and the node crosses
* `onComplete` automatically.
*/
async reportProgress(objectiveId: string, amount = 1): Promise<void> {
await this.context.updateProgress(objectiveId, amount);
}
}
// ---- BattlePassSession ----
/**
* A scenario battlepass node. Exposes the battlepass operations (xp, premium,
* progress) bound to this node's scenario/node/run ids, plus explicit boundary
* crossings (`onLevelUp`, `onPremiumPurchase`) the game drives from its UI. The
* server validates each crossing (e.g. onLevelUp requires the level be reached).
*/
export class BattlePassSession {
constructor(
private readonly context: ScenarioNodeContext,
private readonly battlePass: BattlePassService,
) {}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
private ids(): { scenarioId: string; nodeId: string; runId: string } {
return {
scenarioId: this.context.run.scenarioId,
nodeId: this.context.node.id!,
runId: this.context.run.runId,
};
}
/** Current xp/level/premium/claimed-tiers for this node. */
getProgress(): Promise<GetBattlePassProgressResponse> {
const { scenarioId, nodeId } = this.ids();
return this.battlePass.getProgress(scenarioId, nodeId);
}
/** Credits XP from a configured source. */
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse> {
const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId });
}
/** Claims a tier reward at a reached level. */
claimReward(level: number, track?: string): Promise<ClaimBattlePassRewardResponse> {
const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId });
}
/** Purchases premium, then crosses `onPremiumPurchase` on success. */
async purchasePremium(): Promise<PurchaseBattlePassPremiumResponse> {
const { scenarioId, nodeId, runId } = this.ids();
const response = await this.battlePass.purchasePremium({
scenarioId,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
if (response.success) {
await this.context.complete('onPremiumPurchase');
}
return response;
}
/** Crosses `onLevelUp` (server validates the node's level was reached). */
async levelUp(): Promise<void> {
await this.context.complete('onLevelUp');
}
/** Ends the battlepass node via `onComplete`. */
async end(): Promise<void> {
await this.context.complete('onComplete');
}
}
// ---- BattlePassLevelSession ----
/**
* A scenario battlepass_level node — a single claimable tier. `claim()` crosses
* `onComplete`, which the server accepts only once the player has reached the
* node's configured level.
*/
export class BattlePassLevelSession {
constructor(private readonly context: ScenarioNodeContext) {}
get level(): number {
return this.context.get('level', 0);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
/** Claims this tier; crosses `onComplete` (server checks level reached). */
async claim(): Promise<void> {
await this.context.complete('onComplete');
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { ExecutionPlan } from '../../generated/common.js';
/**
* Active node state within a running scenario plan.
* For wait nodes, this includes the deadline.
*/
export interface ActiveNodeState {
nodeId: string;
waitDeadlineUtc?: string; // ISO 8601 string, set for wait nodes
}
/**
* A running scenario plan instance — tracks active nodes and completed handles.
*/
export class RuntimeRun {
public readonly activeNodes = new Map<string, ActiveNodeState>();
public readonly completedHandles = new Set<string>();
/**
* Number of node transitions currently in flight for this run.
* A transition begins when a node starts completing and ends once its
* successors have been activated. While > 0 the run must not be considered
* complete, even if `activeNodes` is transiently empty (e.g. an
* auto-completing remote_config_override node between two client nodes).
*/
public pendingTransitions = 0;
constructor(
public readonly runId: string,
public readonly plan: ExecutionPlan,
) {}
}
/** Serialization format for a persisted run. */
export interface PersistedRun {
runId: string;
plan: ExecutionPlan;
activeNodes: ActiveNodeState[];
completedHandles: string[];
}
/** Top-level serialization format for all active runs. */
export interface PersistedState {
runs: PersistedRun[];
}
/** Read-only snapshot of a running plan, surfaced in events. */
export class PlanRun {
constructor(
public readonly runId: string,
public readonly planId: string,
public readonly scenarioId: string,
public readonly userId: string,
public readonly activeNodeIds: readonly string[],
public readonly plan: ExecutionPlan,
) {}
}
/** Failure event payload. */
export interface ScenarioRunFailedEvent {
run: PlanRun;
nodeId: string;
error: Error;
}
+70
View File
@@ -0,0 +1,70 @@
import type { RemoteConfig } from '../generated/remote-config.js';
import { SyncedState } from './SyncedState.js';
export type RemoteConfigShape = Record<string, unknown>;
type RemoteConfigKey<TConfig extends RemoteConfigShape> = Extract<keyof TConfig, string>;
/**
* RemoteConfigState — typed remote configuration as an observable entity.
*
* `get()` parses values synchronously from the loaded snapshot based on their
* declared `valueType` and returns the default value while not loaded.
*/
export class RemoteConfigState<
TConfig extends RemoteConfigShape = RemoteConfigShape,
> extends SyncedState<Map<string, RemoteConfig>> {
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey): TConfig[TKey] | undefined;
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey, defaultValue: TConfig[TKey]): TConfig[TKey];
get<TKey extends RemoteConfigKey<TConfig>>(
key: TKey,
defaultValue?: TConfig[TKey],
): TConfig[TKey] | undefined {
const config = this.data?.get(key);
if (!config || config.value == null) return defaultValue;
return parseValue(config.value, config.valueType, defaultValue);
}
/**
* Applies a scenario remote_config_override patch to the current snapshot.
*
* @internal
*/
applyOverride(key: string, value: string, valueType = 'json'): void {
const next = new Map(this.data ?? []);
next.set(key, {
key,
value,
valueType,
active: true,
} as RemoteConfig);
this.set(next);
}
}
function parseValue<T>(
value: string,
valueType: string | undefined,
defaultValue: T | undefined,
): T | undefined {
try {
switch ((valueType ?? '').toLowerCase()) {
case 'int':
case 'integer':
return Number.parseInt(value, 10) as unknown as T;
case 'float':
case 'double':
case 'number':
return Number.parseFloat(value) as unknown as T;
case 'bool':
case 'boolean':
return (value === 'true') as unknown as T;
case 'json':
case 'object':
return JSON.parse(value) as T;
default:
return value as unknown as T;
}
} catch {
return defaultValue;
}
}
+150
View File
@@ -0,0 +1,150 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
export const DEFAULT_SYNC_INTERVAL_MS = 30_000;
const REVISIONS_STORAGE_KEY = 'rudder_revisions';
const JITTER_RATIO = 0.2;
/** A domain the sync engine can refresh when its server revision grows. */
export interface SyncableDomain {
readonly syncKey: string;
invalidate(): void;
}
/**
* SyncEngine — polls GET /sdk/v1/sync and invalidates state entities whose
* server-side revision grew.
*
* The first successful sync after login only fixes the baseline (entities
* were just warmed); revisions are persisted to localStorage and cleared on
* logout. Polling pauses while the document is hidden, and poll errors never
* stop the loop (401s are handled by the transport refresh flow).
*/
export class SyncEngine {
private timer: ReturnType<typeof setTimeout> | undefined;
private revisions: Record<string, number> = {};
private baselineEstablished = false;
private running = false;
private polling = false;
private readonly byKey: Map<string, SyncableDomain>;
constructor(
private readonly ctx: RudderContext,
domains: readonly SyncableDomain[],
private readonly intervalMs: number = DEFAULT_SYNC_INTERVAL_MS,
) {
this.byKey = new Map(domains.map((domain) => [domain.syncKey, domain]));
}
start(): void {
if (this.running) return;
this.running = true;
this.revisions = readPersistedRevisions();
this.baselineEstablished = false;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
void this.poll();
}
stop(): void {
this.running = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
this.revisions = {};
this.baselineEstablished = false;
clearPersistedRevisions();
}
private readonly onVisibilityChange = (): void => {
if (this.running && !document.hidden) {
void this.poll();
}
};
private schedule(): void {
if (!this.running) return;
if (this.timer) clearTimeout(this.timer);
const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
this.timer = setTimeout(() => {
void this.poll();
}, this.intervalMs * jitter);
}
private async poll(): Promise<void> {
if (!this.running || this.polling) return;
if (typeof document !== 'undefined' && document.hidden) {
this.schedule();
return;
}
this.polling = true;
try {
const revisions = await api.getRevisions(this.ctx);
this.applyRevisions(revisions ?? {});
} catch {
// Network/server errors must not stop the loop.
} finally {
this.polling = false;
}
this.schedule();
}
private applyRevisions(next: Record<string, number>): void {
// On the first sync of a session, only establish the baseline UNLESS we
// restored revisions persisted by a previous session — in that case diff
// against them so entities changed while the tab was gone are refreshed.
const skipDiff = !this.baselineEstablished && Object.keys(this.revisions).length === 0;
this.baselineEstablished = true;
if (!skipDiff) {
for (const [key, revision] of Object.entries(next)) {
if (typeof revision !== 'number') continue;
if (revision > (this.revisions[key] ?? 0)) {
this.invalidate(key);
}
}
}
this.revisions = next;
persistRevisions(next);
}
private invalidate(key: string): void {
// Unknown keys have no registered domain and are ignored.
this.byKey.get(key)?.invalidate();
}
}
function readPersistedRevisions(): Record<string, number> {
try {
const raw = localStorage.getItem(REVISIONS_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object'
? (parsed as Record<string, number>)
: {};
} catch {
return {};
}
}
function persistRevisions(revisions: Record<string, number>): void {
try {
localStorage.setItem(REVISIONS_STORAGE_KEY, JSON.stringify(revisions));
} catch {
// Storage unavailable — best effort.
}
}
function clearPersistedRevisions(): void {
try {
localStorage.removeItem(REVISIONS_STORAGE_KEY);
} catch {
// Storage unavailable — silently ignore.
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* SyncedState — an observable cell for a single server-synced entity.
*
* Tracks `{ status, data, error }`, deduplicates parallel loads, and notifies
* listeners on every data/status change. `onChange` calls the callback
* immediately with the current snapshot, which makes syncing into external
* stores (React/zustand) trivial.
*/
export type SyncedStatus = 'idle' | 'loading' | 'ready' | 'error';
export interface SyncedSnapshot<T> {
status: SyncedStatus;
data?: T;
error?: Error;
}
export type SyncedListener<T> = (snapshot: SyncedSnapshot<T>) => void;
export class SyncedState<T> {
private snapshot: SyncedSnapshot<T> = { status: 'idle' };
private pending: Promise<T> | undefined;
private generation = 0;
private invalidateScheduled = false;
private readonly listeners = new Set<SyncedListener<T>>();
constructor(private readonly loader: () => Promise<T>) {}
/** Current data, or undefined when not loaded yet. */
get data(): T | undefined {
return this.snapshot.data;
}
get status(): SyncedStatus {
return this.snapshot.status;
}
get error(): Error | undefined {
return this.snapshot.error;
}
/**
* Subscribes to snapshot changes. The callback fires immediately with the
* current snapshot. Returns an unsubscribe function.
*/
onChange(callback: SyncedListener<T>): () => void {
this.listeners.add(callback);
callback(this.snapshot);
return () => {
this.listeners.delete(callback);
};
}
/** Loads the entity if needed; deduplicates parallel loads. */
load(): Promise<T> {
if (this.snapshot.status === 'ready') {
return Promise.resolve(this.snapshot.data as T);
}
return this.startLoad(false);
}
/** Forces a (re)load even when data is already present. */
reload(): Promise<T> {
return this.startLoad(true);
}
/**
* Refetches when the entity is in use (has listeners or was loaded before);
* otherwise no-ops. Coalesced: multiple invalidations within the same tick
* (e.g. one mutation touching several boundaries) trigger a single reload.
*
* @internal
*/
invalidate(): void {
if (this.invalidateScheduled) return;
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
this.invalidateScheduled = true;
queueMicrotask(() => {
// A reset() or set() between scheduling and flushing cancels the reload
// (fresh data arrived, or the entity was dropped on logout).
if (!this.invalidateScheduled) return;
this.invalidateScheduled = false;
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
this.startLoad(true).catch(() => {});
});
}
/**
* Applies externally-provided data (e.g. scenario config patches).
*
* @internal
*/
set(data: T): void {
this.generation++;
this.pending = undefined;
this.invalidateScheduled = false;
this.setSnapshot({ status: 'ready', data });
}
/**
* Drops all state back to idle (logout).
*
* @internal
*/
reset(): void {
this.generation++;
this.pending = undefined;
this.invalidateScheduled = false;
this.setSnapshot({ status: 'idle' });
}
private startLoad(force: boolean): Promise<T> {
if (this.pending && !force) return this.pending;
const generation = ++this.generation;
this.setSnapshot({ status: 'loading', data: this.snapshot.data });
this.pending = this.loader()
.then((data) => {
if (generation === this.generation) {
this.setSnapshot({ status: 'ready', data });
}
return data;
})
.catch((err) => {
const error = err instanceof Error ? err : new Error(String(err));
if (generation === this.generation) {
this.setSnapshot({ status: 'error', error, data: this.snapshot.data });
}
throw error;
})
.finally(() => {
if (generation === this.generation) {
this.pending = undefined;
}
});
return this.pending;
}
private setSnapshot(next: SyncedSnapshot<T>): void {
const previous = this.snapshot;
if (
previous.status === next.status &&
previous.data === next.data &&
previous.error === next.error
) {
return;
}
this.snapshot = next;
for (const callback of this.listeners) {
callback(next);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { CatalogItem } from '../generated/catalog.js';
import type { PlayerInventoryItem } from '../generated/inventory.js';
/** A fully-resolved owned item: player inventory row merged with its catalog entry. */
export interface InventoryItem {
slug: string;
amount: number;
name: string;
properties: Record<string, unknown>;
tags: string[];
}
/** @internal Merges owned inventory rows with their catalog entries. */
export function mergeInventory(
owned: PlayerInventoryItem[],
catalog: Map<string, CatalogItem>,
): InventoryItem[] {
return owned.map((item) => {
const slug = item.slug ?? '';
const entry = catalog.get(slug);
return {
slug,
amount: item.amount ?? 0,
name: item.nameOverride || entry?.name || slug,
properties: { ...(entry?.properties ?? {}), ...(item.propertiesOverride ?? {}) },
tags: entry?.tags ?? [],
};
});
}
+72
View File
@@ -0,0 +1,72 @@
import type {
Offer,
OfferContent,
OfferPrice,
Store,
PurchaseOfferResponse,
} from '../generated/stores.js';
export interface BuyOptions {
idempotencyKey?: string;
}
/**
* Wire-level purchase executor provided by the stores domain
* (`StoresDomain.purchase`).
*/
export type PurchaseOffer = (
storeSlug: string,
offerId: string,
options?: BuyOptions,
) => Promise<PurchaseOfferResponse>;
export class OfferHandle {
public readonly id: string;
public readonly name?: string;
public readonly price?: OfferPrice;
public readonly contents: OfferContent[];
public readonly maxPurchases?: number;
constructor(
private readonly purchase: PurchaseOffer,
private readonly storeSlug: string,
private readonly offer: Offer,
) {
if (!offer.id) {
throw new Error('Rudder Stores: offer.id is required');
}
this.id = offer.id;
this.name = offer.name;
this.price = offer.price;
this.contents = offer.contents ?? [];
this.maxPurchases = offer.maxPurchases;
}
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
return this.purchase(this.storeSlug, this.id, options);
}
}
export class ShopHandle {
public readonly slug: string;
public readonly name?: string;
public readonly description?: string;
public readonly data?: { [key: string]: unknown };
public readonly offers: OfferHandle[];
constructor(
purchase: PurchaseOffer,
store: Store,
) {
if (!store.slug) {
throw new Error('Rudder Stores: store.slug is required');
}
this.slug = store.slug;
this.name = store.name;
this.description = store.description;
this.data = store.data;
this.offers = (store.offers ?? []).map(
(offer) => new OfferHandle(purchase, this.slug, offer),
);
}
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Token persistence interface and localStorage-backed default implementation.
*
* The TokenStore is injected via RudderClientOptions. When none is provided
* the client uses {@link createDefaultTokenStore}: localStorage in browsers,
* with a silent in-memory fallback where localStorage is unavailable
* (SSR, private mode). Consumers can provide a custom implementation for
* alternative storage backends (e.g., sessionStorage, secure cookies).
*/
export interface TokenStore {
/** Returns the current access token, or null if not authenticated. */
getAccessToken(): string | null;
/** Returns the current refresh token, or null if not available. */
getRefreshToken(): string | null;
/** Persists access and refresh tokens after a successful login. */
saveTokens(accessToken: string, refreshToken: string): void;
/** Removes all stored tokens (logout). */
clear(): void;
}
const STORAGE_KEY_ACCESS = 'rudder_access_token';
const STORAGE_KEY_REFRESH = 'rudder_refresh_token';
/**
* Creates the default TokenStore: localStorage-backed when usable, otherwise
* a silent in-memory fallback (tokens live for the page session only).
*/
export function createDefaultTokenStore(): TokenStore {
try {
const probe = 'rudder_storage_probe';
localStorage.setItem(probe, '1');
localStorage.removeItem(probe);
return createLocalStorageTokenStore();
} catch {
return createMemoryTokenStore();
}
}
/**
* Creates a TokenStore backed by the browser's localStorage.
*
* Uses keys `rudder_access_token` and `rudder_refresh_token`.
*/
export function createLocalStorageTokenStore(): TokenStore {
return {
getAccessToken(): string | null {
try {
return localStorage.getItem(STORAGE_KEY_ACCESS);
} catch {
return null;
}
},
getRefreshToken(): string | null {
try {
return localStorage.getItem(STORAGE_KEY_REFRESH);
} catch {
return null;
}
},
saveTokens(accessToken: string, refreshToken: string): void {
try {
localStorage.setItem(STORAGE_KEY_ACCESS, accessToken);
localStorage.setItem(STORAGE_KEY_REFRESH, refreshToken);
} catch {
// Storage full or unavailable — silently ignore.
}
},
clear(): void {
try {
localStorage.removeItem(STORAGE_KEY_ACCESS);
localStorage.removeItem(STORAGE_KEY_REFRESH);
} catch {
// Storage unavailable — silently ignore.
}
},
};
}
function createMemoryTokenStore(): TokenStore {
let accessToken: string | null = null;
let refreshToken: string | null = null;
return {
getAccessToken: () => accessToken,
getRefreshToken: () => refreshToken,
saveTokens(access, refresh) {
accessToken = access;
refreshToken = refresh;
},
clear() {
accessToken = null;
refreshToken = null;
},
};
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Core HTTP transport — fetch-based with auth injection, timeouts, GET retries,
* single-flight token refresh, and typed error mapping.
*/
import { RudderNetworkError, RudderHttpError, RudderAuthError } from '../client/RudderError.js';
import type { TokenStore } from '../token/TokenStore.js';
import type {
RefreshAccessTokenRequest,
RefreshAccessTokenResponse,
} from '../generated/auth.js';
export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
const MAX_GET_RETRIES = 2;
const RETRY_BASE_DELAY_MS = 300;
export interface RequestContext {
baseUrl: string;
tokenStore: TokenStore;
requestTimeoutMs?: number;
/** Single-flight refresh state shared by all requests of one client. */
refreshState?: { pending?: Promise<boolean> };
/** Called when the refresh flow fails — lets the client stop its runtime. */
onAuthFailure?: () => void;
}
/**
* Sends an HTTP request to the LiveOps API.
*
* - Builds absolute URL from `baseUrl + path`
* - Injects `Authorization: Bearer <token>` header (if token is available)
* - Serializes body as JSON, deserializes response as JSON
* - Aborts the request after `requestTimeoutMs` (default 10s)
* - Retries GET requests up to 2 times (300ms × 2^n backoff) on network errors and 5xx
* - On 401 runs a single-flight token refresh and retries the request once;
* when the refresh fails, clears tokens and throws RudderAuthError
* - Maps HTTP errors to typed exceptions (RudderNetworkError, RudderHttpError, RudderAuthError)
* - Returns undefined for 204 No Content responses
*/
export async function request<TResponse>(
method: string,
path: string,
body: unknown | undefined,
ctx: RequestContext,
): Promise<TResponse> {
let response = await requestWithRetry(method, path, body, ctx);
if (response.status === 401) {
const refreshed = await refreshTokens(ctx);
if (refreshed) {
response = await requestWithRetry(method, path, body, ctx);
} else {
ctx.tokenStore.clear();
ctx.onAuthFailure?.();
const bodyText = await response.text().catch(() => '');
throw new RudderAuthError(response.statusText, bodyText);
}
}
if (!response.ok) {
const bodyText = await response.text().catch(() => '');
// Try to extract machine-readable code from error body.
let code: string | undefined;
try {
const parsed = JSON.parse(bodyText);
if (parsed && typeof parsed.code === 'string') {
code = parsed.code;
}
} catch {
// non-JSON body — ignore
}
if (response.status === 401) {
throw new RudderAuthError(response.statusText, bodyText);
}
throw new RudderHttpError(response.status, response.statusText, bodyText, code);
}
// 204 No Content — no body to parse.
if (response.status === 204) {
return undefined as unknown as TResponse;
}
return response.json() as Promise<TResponse>;
}
/** Fetches with per-attempt timeout; retries GET on network errors and 5xx. */
async function requestWithRetry(
method: string,
path: string,
body: unknown | undefined,
ctx: RequestContext,
): Promise<Response> {
const retryable = method === 'GET';
for (let attempt = 0; ; attempt++) {
try {
const response = await fetchOnce(method, path, body, ctx);
if (retryable && response.status >= 500 && attempt < MAX_GET_RETRIES) {
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
continue;
}
return response;
} catch (err) {
if (retryable && attempt < MAX_GET_RETRIES) {
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
continue;
}
throw new RudderNetworkError(err);
}
}
}
async function fetchOnce(
method: string,
path: string,
body: unknown | undefined,
ctx: RequestContext,
): Promise<Response> {
const url = `${ctx.baseUrl.replace(/\/+$/, '')}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const token = ctx.tokenStore.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const controller = new AbortController();
const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const timer = setTimeout(() => controller.abort(), timeout);
try {
return await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
/**
* Single-flight refresh: concurrent 401s share one refresh request.
* Resolves to true when a new token pair was stored.
*/
async function refreshTokens(ctx: RequestContext): Promise<boolean> {
const state = (ctx.refreshState ??= {});
state.pending ??= doRefreshTokens(ctx).finally(() => {
state.pending = undefined;
});
return state.pending;
}
async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
const refreshToken = ctx.tokenStore.getRefreshToken();
if (!refreshToken) return false;
const url = `${ctx.baseUrl.replace(/\/+$/, '')}/sdk/v1/authorization/refresh`;
const controller = new AbortController();
const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken } satisfies RefreshAccessTokenRequest),
signal: controller.signal,
});
if (!response.ok) return false;
const tokens = (await response.json()) as RefreshAccessTokenResponse;
if (!tokens.accessToken || !tokens.refreshToken) return false;
ctx.tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken);
return true;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+23
View File
@@ -0,0 +1,23 @@
/**
* URL utility helpers for encoding and query string construction.
*/
/** URL-encodes a single value for use in path/query segments. */
export function encodeUrl(value: string): string {
return encodeURIComponent(value);
}
/**
* Builds a query string from an array of key-value pairs.
* Null/undefined/empty values are skipped.
* Returns an empty string if no valid params, or a leading `?` followed by the query.
*/
export function buildQuery(params: Array<[string, string | null | undefined]>): string {
const parts: string[] = [];
for (const [key, value] of params) {
if (value != null && value !== '') {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return parts.length === 0 ? '' : `?${parts.join('&')}`;
}
+264
View File
@@ -0,0 +1,264 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createTestClient } from './helpers/createClient.js';
import { RudderNetworkError, RudderAuthError } from '../src/client/RudderError.js';
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
describe('AuthService', () => {
beforeEach(() => {
// Mock crypto.randomUUID for deterministic device ID.
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
localStorage.clear();
});
it('loginWithDevice sends correct request body', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
}));
});
vi.stubGlobal('fetch', fetchMock);
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
expect(fetchMock).toHaveBeenCalledTimes(8);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/sdk/v1/authorization/device');
const body = JSON.parse(init.body);
expect(body.key).toBe('test-project-key');
expect(body.deviceId).toBe('00000000-0000-4000-a000-000000000001');
expect(body.region).toBe('us');
expect(body.language).toBe('en');
expect(response.accessToken).toBe('access-token-123');
expect(response.refreshToken).toBe('refresh-token-456');
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
'/sdk/v1/authorization/device',
'/sdk/v1/remote-configs',
'/sdk/v1/player/information',
'/sdk/v1/stores',
'/sdk/v1/catalog',
'/sdk/v1/scenarios/trigger',
// The login event invalidates the warmed profile → refetch.
'/sdk/v1/player/information',
// Sync engine baseline poll, fired right after login.
'/sdk/v1/sync',
]);
});
it('loginWithDevice saves tokens on success', async () => {
const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({
accessToken: 'at',
refreshToken: 'rt',
}));
}));
await client.auth.loginWithDevice();
expect(client.options.tokenStore.getAccessToken()).toBe('at');
expect(client.options.tokenStore.getRefreshToken()).toBe('rt');
});
it('loginWithDevice throws RudderNetworkError on network failure', async () => {
const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Offline')));
await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderNetworkError);
});
it('loginWithDevice throws RudderAuthError on 401', async () => {
const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
));
await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderAuthError);
});
it('logout clears tokens', () => {
const client = createTestClient();
client.options.tokenStore.saveTokens('at', 'rt');
client.auth.logout();
expect(client.options.tokenStore.getAccessToken()).toBeNull();
expect(client.options.tokenStore.getRefreshToken()).toBeNull();
});
it('loginWithDevice sends token in Authorization header after login', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at2',
refreshToken: 'rt2',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
// Subsequent authenticated calls should include the token.
const response = await client.player.reload();
expect(response).toEqual({ player: null, wallets: [] });
const playerCall = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/sdk/v1/player/information'),
);
const headers = playerCall?.[1].headers;
expect(headers['Authorization']).toBe('Bearer at2');
});
it('loginWithDevice only fires player_login from the client runtime', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at-new',
refreshToken: 'rt-new',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(jsonResponse({ plans: [] }));
}
if (url.includes('/sdk/v1/stores')) {
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
const triggerBodies = fetchMock.mock.calls
.filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger'))
.map(([, init]) => JSON.parse(init.body));
expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']);
});
it('loginWithDevice isolates post-login scenario trigger failures', async () => {
const client = createTestClient();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at',
refreshToken: 'rt',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' }));
}
if (url.includes('/sdk/v1/stores')) {
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
}));
await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' });
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('loginWithDevice defaults to region global and language en', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
const [, init] = fetchMock.mock.calls[0];
const body = JSON.parse(init.body);
expect(body.region).toBe('global');
expect(body.language).toBe('en');
});
});
describe('auth state', () => {
beforeEach(() => {
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
localStorage.clear();
});
function stubLoginFlow(): void {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(jsonResponse({ plans: [] }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
}));
}
it('onAuthStateChange fires immediately, then on login and logout', async () => {
const client = createTestClient();
stubLoginFlow();
const states: string[] = [];
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
expect(states).toEqual(['signed-out']);
expect(client.auth.isAuthenticated).toBe(false);
await client.auth.loginWithDevice();
expect(states).toEqual(['signed-out', 'signed-in']);
expect(client.auth.isAuthenticated).toBe(true);
client.auth.logout();
expect(states).toEqual(['signed-out', 'signed-in', 'signed-out']);
expect(client.auth.isAuthenticated).toBe(false);
unsubscribe();
client.auth.logout();
expect(states).toHaveLength(3);
});
it('starts signed-in when the token store already holds a token', () => {
const client = createTestClient();
client.options.tokenStore.saveTokens('at', 'rt');
const states: string[] = [];
client.auth.onAuthStateChange((state) => states.push(state));
expect(states).toEqual(['signed-in']);
});
it('emits signed-out when the transport session expires (refresh fails)', async () => {
const client = createTestClient();
client.options.tokenStore.saveTokens('expired-at', 'expired-rt');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
));
const states: string[] = [];
client.auth.onAuthStateChange((state) => states.push(state));
expect(states).toEqual(['signed-in']);
await expect(client.player.reload()).rejects.toThrow(RudderAuthError);
expect(states).toEqual(['signed-in', 'signed-out']);
expect(client.auth.isAuthenticated).toBe(false);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import * as sdk from '../src/index.js';
import { RudderClient } from '../src/index.js';
describe('public API surface', () => {
it('does not expose scenario runtime types from the root package', () => {
expect('ScenarioService' in sdk).toBe(false);
expect('NotificationSession' in sdk).toBe(false);
expect('StoreSession' in sdk).toBe(false);
expect('WaitSession' in sdk).toBe(false);
expect('LeaderboardSession' in sdk).toBe(false);
expect('PlanRun' in sdk).toBe(false);
expect('createIndexedDbPlanStateStore' in sdk).toBe(false);
});
it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
// Device id helper — internal.
expect('getOrCreateDeviceId' in sdk).toBe(false);
// Domain classes — exported as types only.
for (const name of [
'PlayerDomain',
'CatalogDomain',
'InventoryDomain',
'ConfigDomain',
'StorageDomain',
'StoresDomain',
]) {
expect(name in sdk).toBe(false);
}
// Service constructors — exported as types only.
for (const name of [
'AuthService',
'LeaderboardsService',
'LeaderboardHandle',
'BattlePassService',
'BattlepassService',
'QuestsService',
]) {
expect(name in sdk).toBe(false);
}
});
it('exposes the expected value exports', () => {
for (const name of [
'RudderClient',
'RudderError',
'RudderNetworkError',
'RudderHttpError',
'RudderAuthError',
'SDK_ERROR_INVALID_OPTIONS',
'RudderErrorCodes',
'createDefaultTokenStore',
'createLocalStorageTokenStore',
'SyncedState',
'RemoteConfigState',
'ShopHandle',
'OfferHandle',
]) {
expect(name in sdk, name).toBe(true);
}
});
it('exposes effects without a public scenarios service on the client', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null },
});
expect(client.effects).toBeDefined();
expect('scenarios' in client).toBe(false);
});
it('uses the canonical glossary property names on the client', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null },
});
expect(client.remoteConfig).toBeDefined();
expect('config' in client).toBe(false);
expect(client.battlePass).toBeDefined();
expect('battlepass' in client).toBe(false);
expect(typeof client.effects.onBattlePass).toBe('function');
expect(typeof client.effects.onBattlePassLevel).toBe('function');
expect('onBattlepass' in client.effects).toBe(false);
expect('onBattlepassLevel' in client.effects).toBe(false);
});
});
+217
View File
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RudderClient } from '../src/client/RudderClient.js';
import {
RudderError,
RudderNetworkError,
RudderHttpError,
RudderAuthError,
SDK_ERROR_INVALID_OPTIONS,
} from '../src/client/RudderError.js';
import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
describe('RudderClient', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('constructs with valid options', () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
tokenStore: createFakeTokenStore(),
});
expect(client.auth).toBeDefined();
});
it('throws RudderError with sdk/invalid-options if baseUrl is missing', () => {
let caught: unknown;
try {
new RudderClient({ baseUrl: '', projectKey: 'proj_123' });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RudderError);
expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS);
expect((caught as Error).message).toContain('baseUrl');
});
it('throws RudderError with sdk/invalid-options if projectKey is missing', () => {
let caught: unknown;
try {
new RudderClient({ baseUrl: 'https://api.example.com', projectKey: '' });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RudderError);
expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS);
expect((caught as Error).message).toContain('projectKey');
});
it('creates a default localStorage-backed token store when tokenStore is omitted', () => {
localStorage.clear();
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
});
client.options.tokenStore.saveTokens('at', 'rt');
expect(localStorage.getItem('rudder_access_token')).toBe('at');
expect(client.options.tokenStore.getAccessToken()).toBe('at');
client.options.tokenStore.clear();
});
it('falls back to an in-memory token store when localStorage is unavailable', () => {
vi.stubGlobal('localStorage', undefined);
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
});
client.options.tokenStore.saveTokens('at', 'rt');
expect(client.options.tokenStore.getAccessToken()).toBe('at');
expect(client.options.tokenStore.getRefreshToken()).toBe('rt');
client.options.tokenStore.clear();
expect(client.options.tokenStore.getAccessToken()).toBeNull();
});
});
describe('transport error mapping', () => {
let client: RudderClient;
beforeEach(() => {
client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
tokenStore: createFakeTokenStore(),
});
});
it('throws RudderNetworkError with the underlying error as cause', async () => {
const underlying = new Error('Network down');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(underlying));
let caught: unknown;
await client.player.reload().catch((error) => {
caught = error;
});
expect(caught).toBeInstanceOf(RudderNetworkError);
expect((caught as RudderNetworkError).cause).toBe(underlying);
});
it('throws RudderAuthError on 401', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
));
await expect(client.player.reload())
.rejects.toThrow(RudderAuthError);
});
it('throws RudderHttpError with the server error code on non-401 HTTP error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 'run_expired', error: 'gone' }), {
status: 410,
statusText: 'Gone',
}),
));
let caught: unknown;
await client.player.reload().catch((error) => {
caught = error;
});
expect(caught).toBeInstanceOf(RudderHttpError);
expect((caught as RudderHttpError).code).toBe('run_expired');
});
it('returns undefined for 204 No Content', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(null, { status: 204, statusText: 'No Content' }),
));
const result = await client.storage.save([]);
expect(result).toBeUndefined();
});
it('returns parsed JSON for 200 responses', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ player: { id: 'player-1' }, wallets: [] }), { status: 200 }),
));
const result = await client.player.reload();
expect(result).toEqual({ player: { id: 'player-1' }, wallets: [] });
});
});
describe('lazy scenario runtime', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not create the scenario runtime until first use', async () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: { planStateStore: null, loginEvent: null },
});
const internals = client as unknown as {
scenarioRuntime: unknown;
ensureScenarioRuntime(): Promise<unknown>;
};
expect(internals.scenarioRuntime).toBeNull();
await internals.ensureScenarioRuntime();
expect(internals.scenarioRuntime).not.toBeNull();
});
it('delivers effects to handlers subscribed before the runtime is ready', async () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: { planStateStore: null }, // default loginEvent: player_login
});
const notifications: string[] = [];
client.effects.onNotification((effect) => {
notifications.push(effect.message);
return effect.done();
});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = new URL(url).pathname;
if (path === '/sdk/v1/authorization/device') {
return Promise.resolve(new Response(
JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }),
{ status: 200 },
));
}
if (path === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({
plans: [{
planId: 'plan-1',
scenarioId: 'scenario-1',
userId: 'user-1',
startNodeId: 'start',
runId: 'run-1',
nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }],
edges: [],
boundaryNodes: [],
}],
}), { status: 200 }));
}
if (path === '/sdk/v1/remote-configs') {
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
}
if (path === '/sdk/v1/stores') {
return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 }));
}
if (path === '/sdk/v1/catalog') {
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 }));
}
if (path === '/sdk/v1/sync') {
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
}
void init;
return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 }));
}));
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(notifications).toEqual(['Welcome!']));
client.dispose();
});
});
+528
View File
@@ -0,0 +1,528 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ScenarioService } from '../src/scenario/ScenarioService.js';
import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js';
import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge } from '../src/generated/common.js';
import { NotificationSession, StoreSession, WaitSession, LeaderboardSession } from '../src/scenario/engine/sessions.js';
/** Builds a simple execution plan with the given nodes and edges. */
function makePlan(overrides?: Partial<ExecutionPlan>): ExecutionPlan {
return {
planId: 'plan-1',
scenarioId: 'scenario-1',
userId: 'user-1',
startNodeId: 'start',
runId: 'run-1',
nodes: [],
edges: [],
boundaryNodes: [],
context: undefined,
...overrides,
};
}
function makeNode(id: string, type: string, data?: Record<string, unknown>): ExecutionPlanNode {
return { id, type, data: data as ExecutionPlanNode['data'] };
}
function makeEdge(source: string, sourceHandle: string, target: string): PlanEdge {
return { id: `edge-${source}-${target}`, source, sourceHandle, target };
}
async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-key',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null }, // Disable IndexedDB for tests
});
return { client, scenarios: await getScenarioRuntime(client) };
}
describe('ScenarioService', () => {
beforeEach(() => {
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
});
describe('send (trigger)', () => {
it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => {
const { client, scenarios } = await createClientWithScenario();
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
const onNotification = vi.fn();
scenarios.onNotification = onNotification;
const response = await scenarios.send('level_complete');
expect(response.plans).toHaveLength(1);
expect(scenarios.isRunning).toBe(true);
expect(onNotification).toHaveBeenCalledOnce();
expect(onNotification.mock.calls[0][0]).toBeInstanceOf(NotificationSession);
});
});
describe('node dispatch', () => {
it('notification node fires onNotification', async () => {
const { scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
});
it('store node fires onStore', async () => {
const { scenarios } = await createClientWithScenario();
const onStore = vi.fn();
scenarios.onStore = onStore;
const plan = makePlan({
nodes: [makeNode('start', 'store')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onStore).toHaveBeenCalledOnce();
expect(onStore.mock.calls[0][0]).toBeInstanceOf(StoreSession);
});
it('wait node fires onWait', async () => {
const { scenarios } = await createClientWithScenario();
const onWait = vi.fn();
scenarios.onWait = onWait;
const plan = makePlan({
nodes: [makeNode('start', 'wait', { duration: 5, unit: 'minutes' })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
expect(onWait.mock.calls[0][0]).toBeInstanceOf(WaitSession);
});
it('leaderboard node fires onLeaderboard', async () => {
const { scenarios } = await createClientWithScenario();
const onLb = vi.fn();
scenarios.onLeaderboard = onLb;
const plan = makePlan({
nodes: [makeNode('start', 'leaderboard')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onLb).toHaveBeenCalledOnce();
expect(onLb.mock.calls[0][0]).toBeInstanceOf(LeaderboardSession);
});
it('quest node dispatches onQuest instead of stalling', async () => {
const { client, scenarios } = await createClientWithScenario();
const onQuest = vi.fn();
client.effects.onQuest(onQuest);
const plan = makePlan({
nodes: [
makeNode('start', 'quest', {
name: 'Daily',
objectives: [{ objectiveId: 'kills', target: 10 }],
}),
],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onQuest).toHaveBeenCalledOnce();
expect(onQuest.mock.calls[0][0].name).toBe('Daily');
// Active and waiting for progress — not stalled, not failed.
expect(scenarios.isRunning).toBe(true);
});
it('battlepass node dispatches onBattlePass', async () => {
const { client, scenarios } = await createClientWithScenario();
const onBp = vi.fn();
client.effects.onBattlePass(onBp);
const plan = makePlan({
nodes: [makeNode('start', 'battlepass', { premiumPrice: 100 })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onBp).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(true);
});
it('battlepass_level node dispatches onBattlePassLevel', async () => {
const { client, scenarios } = await createClientWithScenario();
const onLevel = vi.fn();
client.effects.onBattlePassLevel(onLevel);
const plan = makePlan({
nodes: [makeNode('start', 'battlepass_level', { level: 3 })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onLevel).toHaveBeenCalledOnce();
expect(onLevel.mock.calls[0][0].level).toBe(3);
});
it('remote_config_override node applies patches internally and auto-completes', async () => {
const { client, scenarios } = await createClientWithScenario();
const plan = makePlan({
nodes: [
makeNode('start', 'remote_config_override', {
patches: [
{ path: 'difficulty', valueType: 'string', value: 'hard' },
],
}),
],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(client.remoteConfig.get('difficulty', '')).toBe('hard');
// RemoteConfigOverride auto-completes asynchronously — wait for the promise.
await new Promise((r) => setTimeout(r, 50));
expect(scenarios.isRunning).toBe(false);
});
it('unsupported node type fails the run and surfaces onScenarioFailed', async () => {
const { client, scenarios } = await createClientWithScenario();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const onFailed = vi.fn();
client.effects.onScenarioFailed(onFailed);
const plan = makePlan({
nodes: [makeNode('start', 'unknown_type')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Unsupported scenario node type'),
);
// The run must NOT stall: it fails and the game is notified.
expect(onFailed).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(false);
warn.mockRestore();
});
});
describe('DAG traversal', () => {
it('completing a node follows matching edges', async () => {
const { scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
// start(notif) → complete("output") → node2(notif)
const plan = makePlan({
nodes: [
makeNode('start', 'notification'),
makeNode('node2', 'notification'),
],
edges: [makeEdge('start', 'output', 'node2')],
});
let callCount = 0;
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
}
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
}));
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce(); // start node dispatched
// Complete the notification node with handle "output"
const session = onNotif.mock.calls[0][0] as NotificationSession;
await session.complete();
expect(onNotif).toHaveBeenCalledTimes(2);
});
it('completing a node with already-completed handle is idempotent', async () => {
const { scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
const plan = makePlan({
nodes: [
makeNode('start', 'notification'),
makeNode('node2', 'notification'),
],
edges: [makeEdge('start', 'output', 'node2')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(scenarios.activeRuns).toHaveLength(1);
const session = onNotif.mock.calls[0][0] as NotificationSession;
await session.complete();
// node2 should now be active
expect(scenarios.activeRuns).toHaveLength(1);
// Complete node2
const session2 = onNotif.mock.calls[1][0] as NotificationSession;
await session2.complete();
// Run should be completed
expect(scenarios.isRunning).toBe(false);
});
it('run completion fires onRunCompleted and onCompleted', async () => {
const { scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onRunCompleted = vi.fn();
scenarios.onCompleted = onCompleted;
scenarios.onRunCompleted = onRunCompleted;
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
const session = (scenarios as unknown as { onNotification?: (s: NotificationSession) => void }).onNotification?.(
// Use the mock calls to get the session
vi.mocked(vi.fn()).mock.calls[0]?.[0] as NotificationSession,
);
// We need to get the session from the mock spy
// Actually let's trigger via respond()
scenarios.respond('output');
// Fire-and-forget — wait a tick
await new Promise((r) => setTimeout(r, 10));
expect(onRunCompleted).toHaveBeenCalled();
expect(onCompleted).toHaveBeenCalled();
});
});
describe('wait nodes', () => {
it('sets a deadline and fires onWait', async () => {
const { scenarios } = await createClientWithScenario();
const onWait = vi.fn();
scenarios.onWait = onWait;
const plan = makePlan({
nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
const session = onWait.mock.calls[0][0] as WaitSession;
expect(session.deadlineUtc).toBeInstanceOf(Date);
// Deadline should be ~10 minutes from now.
const diff = session.deadlineUtc.getTime() - Date.now();
expect(diff).toBeGreaterThan(9 * 60 * 1000);
expect(diff).toBeLessThan(11 * 60 * 1000);
});
it('completes immediately if deadline has already passed', async () => {
const { scenarios } = await createClientWithScenario();
const onWait = vi.fn();
const onCompleted = vi.fn();
scenarios.onWait = onWait;
scenarios.onCompleted = onCompleted;
// Duration of 0 should result in an immediate completion.
const plan = makePlan({
nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
// Wait for the setTimeout(0) to fire.
await new Promise((r) => setTimeout(r, 50));
expect(onCompleted).toHaveBeenCalled();
});
});
describe('store session', () => {
it('buy() purchases the selected offer and completes with onPurchase', async () => {
const { scenarios } = await createClientWithScenario();
const onStore = vi.fn();
scenarios.onStore = onStore;
const plan = makePlan({
nodes: [makeNode('start', 'store', { storeSlug: 'starter' })],
});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const parsed = new URL(url);
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
}
if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') {
return Promise.resolve(new Response(JSON.stringify({
name: 'Starter',
slug: 'starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
}), { status: 200 }));
}
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/stores/starter/offers/pack_1/purchase') {
return Promise.resolve(new Response(JSON.stringify({ success: true, purchaseId: 'purchase-1' }), { status: 200 }));
}
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
}));
await scenarios.send('test');
const session = onStore.mock.calls[0][0] as StoreSession;
const onCompleted = vi.fn();
scenarios.onCompleted = onCompleted;
const store = await session.getStore();
const purchase = await session.buy(store.offers[0]);
expect(purchase.success).toBe(true);
expect(session.isResolved).toBe(true);
// Second call should be a no-op.
const duplicate = await session.buy(store.offers[0]);
expect(duplicate.success).toBe(false);
});
it('decline() completes with onDecline', async () => {
const { scenarios } = await createClientWithScenario();
const onStore = vi.fn();
scenarios.onStore = onStore;
const plan = makePlan({
nodes: [makeNode('start', 'store')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
const session = onStore.mock.calls[0][0] as StoreSession;
await session.decline();
expect(session.isResolved).toBe(true);
});
});
describe('respond / clear', () => {
it('respond completes the first active node', async () => {
const { scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
const plan = makePlan({
nodes: [
makeNode('start', 'notification'),
makeNode('node2', 'notification'),
],
edges: [makeEdge('start', 'output', 'node2')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
// respond() completes the first active node with the given handle.
scenarios.respond('output');
// Wait for async completion.
await new Promise((r) => setTimeout(r, 50));
// node2 should now be active (second notification dispatched).
expect(scenarios.isRunning).toBe(true);
});
it('clear removes all runs', async () => {
const { scenarios } = await createClientWithScenario();
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(scenarios.isRunning).toBe(true);
scenarios.clear();
expect(scenarios.isRunning).toBe(false);
});
});
describe('runId filtering', () => {
it('rejects plans with duplicate runId', async () => {
const { scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
scenarios.onNotification = onNotification;
const plan = makePlan({
runId: 'run-1',
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })),
));
// First send creates a run
await scenarios.send('first-event');
expect(scenarios.activeRuns).toHaveLength(1);
expect(onNotification).toHaveBeenCalledTimes(1);
// Second send with same runId is rejected by startPlan dedup
await scenarios.send('second-event');
expect(scenarios.activeRuns).toHaveLength(1);
expect(onNotification).toHaveBeenCalledTimes(1);
});
});
});
+77
View File
@@ -0,0 +1,77 @@
// Minimal fetch wrapper for the LiveOps platform/admin HTTP API used by the seed
// and by tests' admin-side assertions. Retries network errors and 5xx with backoff;
// fails fast on 4xx.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly path: string,
public readonly body: string,
) {
super(`HTTP ${status} on ${path}: ${body.slice(0, 400)}`);
this.name = 'ApiError';
}
}
export interface AdminClient {
setToken(token: string): void;
readonly token: string | undefined;
get<T>(path: string, opts?: { auth?: boolean }): Promise<T>;
post<T>(path: string, body?: unknown, opts?: { auth?: boolean }): Promise<T>;
put<T>(path: string, body?: unknown, opts?: { auth?: boolean }): Promise<T>;
del<T>(path: string, opts?: { auth?: boolean }): Promise<T>;
}
export function createAdminClient(baseUrl: string): AdminClient {
let token: string | undefined;
async function request<T>(
method: string,
path: string,
body?: unknown,
opts?: { auth?: boolean },
): Promise<T> {
const url = `${baseUrl}${path}`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (opts?.auth !== false && token) headers['Authorization'] = `Bearer ${token}`;
let lastErr: unknown;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (res.status >= 500) {
lastErr = new ApiError(res.status, path, text);
await sleep(300 * (attempt + 1));
continue;
}
if (!res.ok) throw new ApiError(res.status, path, text);
return (text ? JSON.parse(text) : undefined) as T;
} catch (err) {
if (err instanceof ApiError) throw err; // 4xx — don't retry
lastErr = err; // network error — retry
await sleep(300 * (attempt + 1));
}
}
throw lastErr;
}
return {
setToken(t: string) {
token = t;
},
get token() {
return token;
},
get: (path, opts) => request('GET', path, undefined, opts),
post: (path, body, opts) => request('POST', path, body, opts),
put: (path, body, opts) => request('PUT', path, body, opts),
del: (path, opts) => request('DELETE', path, undefined, opts),
};
}
+19
View File
@@ -0,0 +1,19 @@
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { SeedArtifact } from './types.js';
export const ARTIFACT_PATH = resolve(process.cwd(), '.e2e-prod.local.json');
export function writeArtifact(artifact: SeedArtifact): void {
writeFileSync(ARTIFACT_PATH, JSON.stringify(artifact, null, 2));
}
export function readArtifact(): SeedArtifact | null {
if (!existsSync(ARTIFACT_PATH)) return null;
return JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8')) as SeedArtifact;
}
export function removeArtifact(): void {
if (existsSync(ARTIFACT_PATH)) rmSync(ARTIFACT_PATH);
}
+48
View File
@@ -0,0 +1,48 @@
// Vitest globalSetup for the production e2e suite.
//
// Setup: seeds a fresh project on prod (unless RUDDER_E2E_PROJECT_KEY is provided) and
// writes .e2e-prod.local.json for the tests to read.
// Teardown: deletes the seeded project (default). Set RUDDER_E2E_KEEP=1 to keep it.
import { runSeed, deleteProject } from './seed.js';
import { readArtifact, writeArtifact, removeArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js';
export default async function setup(): Promise<() => Promise<void>> {
const baseUrl = process.env.RUDDER_E2E_BASE_URL ?? 'https://api.rudder.build';
// Externally-provided project: skip seeding, just verify an artifact/env exists.
if (process.env.RUDDER_E2E_PROJECT_KEY) {
const existing = readArtifact();
if (!existing) {
writeArtifact({
baseUrl,
projectKey: process.env.RUDDER_E2E_PROJECT_KEY,
// Remaining fields are unknown for an external project; tests relying on the
// seeded dataset/admin token will require a real seed run.
} as unknown as SeedArtifact);
}
return async () => {};
}
const log = (m: string) => console.log(`[seed] ${m}`);
log(`seeding ${baseUrl} ...`);
const artifact = await runSeed(baseUrl, log);
writeArtifact(artifact);
log(`seed complete: project=${artifact.projectId}`);
return async () => {
if (process.env.RUDDER_E2E_KEEP === '1') {
console.log('[seed] RUDDER_E2E_KEEP=1 — leaving project in place');
return;
}
try {
await deleteProject(artifact);
console.log(`[seed] deleted project ${artifact.projectId}`);
} catch (err) {
console.warn(`[seed] failed to delete project ${artifact.projectId}: ${String(err)}`);
} finally {
removeArtifact();
}
};
}
+99
View File
@@ -0,0 +1,99 @@
// Test-side helpers: load the seed artifact, build SDK clients against prod, and
// reach the admin API for wallet grants / player inspection.
import { RudderClient } from '../../../src/client/RudderClient.js';
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
import type { TokenStore } from '../../../src/token/TokenStore.js';
import { createAdminClient, type AdminClient } from './adminClient.js';
import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js';
type RuntimeOptions = {
planStateStore?: {
state: string | null;
load(): Promise<void>;
save(state: string | null): Promise<void>;
} | null;
loginEvent?: string | null;
};
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
export function loadArtifact(): SeedArtifact {
const file = readArtifact();
if (!file) {
throw new Error(
'No seed artifact (.e2e-prod.local.json). Run `npm run test:e2e-prod` (globalSetup seeds automatically).',
);
}
return {
...file,
baseUrl: process.env.RUDDER_E2E_BASE_URL ?? file.baseUrl,
projectKey: process.env.RUDDER_E2E_PROJECT_KEY ?? file.projectKey,
};
}
export function createInMemoryTokenStore(): TokenStore {
let access: string | null = null;
let refresh: string | null = null;
return {
getAccessToken: () => access,
getRefreshToken: () => refresh,
saveTokens: (a, r) => {
access = a;
refresh = r;
},
clear: () => {
access = null;
refresh = null;
},
};
}
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
artifact: SeedArtifact = loadArtifact(),
runtime: RuntimeOptions = { planStateStore: null, loginEvent: null },
): RudderClient<TConfig> {
return new RudderClient<TConfig>({
baseUrl: artifact.baseUrl,
projectKey: artifact.projectKey,
tokenStore: createInMemoryTokenStore(),
runtime,
});
}
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>(
artifact: SeedArtifact = loadArtifact(),
runtime?: RuntimeOptions,
): Promise<RudderClient<TConfig>> {
const client = makeProdClient<TConfig>(artifact, runtime);
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
return client;
}
/** Admin (operator) API client authenticated with the seeded operator token. */
export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
const api = createAdminClient(artifact.baseUrl);
api.setToken(artifact.adminToken);
return api;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
export async function withReadRetry<T>(
fn: () => Promise<T>,
ok: (value: T) => boolean,
tries = 8,
gapMs = 1500,
): Promise<T> {
let last = await fn();
if (ok(last)) return last;
for (let i = 1; i < tries; i++) {
await sleep(gapMs);
last = await fn();
if (ok(last)) return last;
}
return last;
}
+311
View File
@@ -0,0 +1,311 @@
// Production seed: registers an operator, creates a project, configures staging
// content (items, remote configs, leaderboard, store with free+paid offers, a
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
// Excludes realtime & analytics.
//
// The scenario flow uses the SDK runtime's exact node `data` keys and handle names —
// the game's plan builder copies node.data verbatim into the execution plan
// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs.
import { createAdminClient, type AdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
interface ReleaseResponse {
id: string;
status: string;
version: number;
}
interface QuestResponse {
id: string;
name: string;
status: string;
objectives?: Array<{ id: string; metric: string; target: number }>;
rewards?: Array<{ itemId?: string; currency?: string; amount: number }>;
}
interface StoreResponse {
id: string;
name: string;
data?: { slug?: string };
offers?: Array<{
id: string;
name: string;
contents?: Array<{ itemId: string; amount: number }>;
}>;
}
function buildFlow(scenarioSlug: string) {
return {
nodes: [
{ id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } },
{ id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } },
{
id: 'rc_1',
type: 'remote_config_override',
data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] },
},
{ id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } },
{ id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } },
{ id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } },
],
edges: [
{ id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' },
{ id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' },
{ id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' },
{ id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' },
{ id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' },
],
};
}
async function pollRelease(
api: AdminClient,
projPath: string,
env: string,
releaseId: string,
log: (m: string) => void,
): Promise<void> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const releases = await api.get<ReleaseResponse[]>(
`${projPath}/releases?environment=${env}&limit=20&offset=0`,
);
const rel = releases.find((r) => r.id === releaseId);
if (rel) {
if (rel.status === 'completed') {
log(`release ${releaseId} completed (v${rel.version})`);
return;
}
if (rel.status === 'failed') {
throw new Error(`release ${releaseId} failed`);
}
}
await sleep(1500);
}
throw new Error(`release ${releaseId} did not complete within 60s`);
}
export async function runSeed(
baseUrl: string,
log: (m: string) => void = () => {},
): Promise<SeedArtifact> {
const api = createAdminClient(baseUrl);
const ts = Date.now();
const authoringEnv = 'staging';
const runtimeEnv = 'prod';
const q = `?environment=${authoringEnv}`;
const prodQ = `?environment=${runtimeEnv}`;
const adminEmail = `e2e+${ts}@rudder.build`;
const password = `Passw0rd!e2e-${ts}`;
// 1. Register operator (open registration; no approval gate at the API layer).
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
'/platform/v1/authorization/register',
{ email: adminEmail, password, fullName: 'E2E Bot' },
{ auth: false },
);
api.setToken(auth.accessToken);
log(`registered ${adminEmail}`);
// 2. Project.
const project = await api.post<{ id: string; key: string; name: string }>(
'/platform/v1/projects',
{ name: `e2e-${ts}` },
);
const projPath = `/platform/v1/projects/${project.id}`;
log(`project ${project.id} key=${project.key}`);
// 3. Staging environment (idempotent — may already exist).
try {
await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv });
} catch {
// already exists / not required — content POSTs below are the real test
}
// 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Gold Coin',
tags: ['currency'],
});
// 5. Remote configs — one per value type.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
{ key: 'feature_x', value: 'true', valueType: 'bool' },
{ key: 'welcome_text', value: 'hi', valueType: 'string' },
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
];
for (const rc of remoteConfigs) {
await api.post(`${projPath}/remote-configs${q}`, {
projectId: project.id,
environment: authoringEnv,
...rc,
});
}
log(`created ${remoteConfigs.length} remote configs`);
// 6. Leaderboard.
await api.post(`${projPath}/leaderboards${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Weekly Score',
slug: 'weekly-score',
metric: 'score',
resetPeriod: 'weekly',
sortingOrder: 'desc',
maxEntries: 100,
});
// 7. Store with a free offer and a paid (currency) offer with a purchase limit.
// The runtime resolves a store's slug from data.slug (liveops-game shop cache), and
// the CreateStore API has no slug field — so we set it via data.
const storeSlug = 'starter-shop';
const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>(
`${projPath}/stores${q}`,
{
projectId: project.id,
environment: authoringEnv,
name: 'Starter Shop',
description: 'E2E shop',
data: { slug: storeSlug },
offers: [
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } },
{
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency: 'soft', amount: 100 },
maxPurchases: 2,
},
],
},
);
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack');
if (!paidOffer?.id) {
throw new Error('seeded paid offer did not return an id');
}
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
const questName = 'Buy Gold Pack Quest';
const questObjectiveId = 'buy-gold-pack';
const questTarget = 1;
const questMetric = `purchase.offer:${paidOffer.id}`;
const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
});
log(`quest ${quest.id} staged for metric ${questMetric}`);
// 9. Scenario (must be live now: startAt <= now <= endAt).
const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
endAt,
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}`, {
projectId: project.id,
scenarioId: scenario.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves prod/latest.json).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`);
await pollRelease(api, projPath, runtimeEnv, release.id, log);
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
}
const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack');
if (!prodPaidOffer?.id) {
throw new Error('promoted paid offer did not return an id');
}
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
throw new Error('promoted paid offer did not return a reward item id');
}
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
const prodQuest = prodQuests.find((q) => q.name === questName);
if (!prodQuest?.id) {
throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`);
}
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) {
throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`);
}
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
if (!prodQuestRewardItemId) {
throw new Error(`promoted quest ${questName} did not return reward item id`);
}
// 12. Settle: let the game service hot-reload caches from the new snapshot.
await sleep(3000);
return {
baseUrl,
projectId: project.id,
projectKey: project.key,
adminToken: auth.accessToken,
adminEmail,
environment: runtimeEnv,
seededAt: new Date(ts).toISOString(),
releaseId: release.id,
store: {
name: 'Starter Shop',
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id,
paidPrice: { currency: 'soft', amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
id: prodQuest.id,
name: questName,
objectiveId: questObjectiveId,
metric: prodQuestMetric,
target: questTarget,
rewardItemId: prodQuestRewardItemId,
rewardAmount: questRewardAmount,
},
leaderboard: { slug: 'weekly-score', metric: 'score' },
item: { id: prodRewardItemId, name: item.name },
remoteConfigs: {
maxEnergy: 100,
spawnRate: 1.5,
featureX: true,
welcomeText: 'hi',
shopLayout: { cols: 3 },
spawnRateOverride: 3.0,
},
scenario: { id: scenario.id, event: 'session_start' },
};
}
export async function deleteProject(artifact: SeedArtifact): Promise<void> {
const api = createAdminClient(artifact.baseUrl);
api.setToken(artifact.adminToken);
await api.del(`/platform/v1/projects/${artifact.projectId}`);
}
+46
View File
@@ -0,0 +1,46 @@
// Shared types for the production e2e harness.
/** Persisted seed output, written to .e2e-prod.local.json and read by tests. */
export interface SeedArtifact {
baseUrl: string;
projectId: string;
projectKey: string;
/** Operator (platform) bearer token — used for admin calls (wallet grant, player details). */
adminToken: string;
adminEmail: string;
environment: string;
seededAt: string;
releaseId: string;
store: {
name: string;
slug: string;
freeOfferName: string;
paidOfferName: string;
paidOfferId: string;
paidPrice: { currency: string; amount: number };
paidMaxPurchases: number;
grantedItemName: string;
paidGrantAmount: number;
};
quest: {
id: string;
name: string;
objectiveId: string;
metric: string;
target: number;
rewardItemId: string;
rewardAmount: number;
};
leaderboard: { slug: string; metric: string };
item: { id: string; name: string };
remoteConfigs: {
maxEnergy: number;
spawnRate: number;
featureX: boolean;
welcomeText: string;
shopLayout: Record<string, unknown>;
/** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number;
};
scenario: { id: string; event: string };
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { loadArtifact } from './_setup/harness.js';
// Smoke gate: if egress to prod is blocked or the gateway is down, fail fast here.
describe('e2e-prod: health', () => {
let baseUrl: string;
beforeAll(() => {
baseUrl = loadArtifact().baseUrl;
});
it('GET /health is ok', async () => {
const res = await fetch(`${baseUrl}/health`);
expect(res.ok).toBe(true);
const body = (await res.json()) as { status?: string };
expect(body.status).toBe('ok');
});
it('content + game gRPC backends are serving', async () => {
// /readyz may be 503 if analytics is not_serving (out of scope here); assert the
// backends this suite actually uses are serving.
const res = await fetch(`${baseUrl}/readyz`);
const body = (await res.json()) as { grpc?: Record<string, string> };
expect(body.grpc?.content).toBe('serving');
expect(body.grpc?.game).toBe('serving');
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
describe('e2e-prod: leaderboards', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('submits a score and sees it in the ranking', async () => {
const client = await freshPlayer(artifact);
const me = (await client.player.reload()).player?.id;
expect(me).toBeTruthy();
const lb = client.leaderboards.findBySlug(artifact.leaderboard.slug);
const score = 4242;
await lb.submit(score);
const entries = await withReadRetry(
() => lb.list(50),
(list) => list.some((e) => e.playerId === me),
);
const mine = entries.find((e) => e.playerId === me);
expect(mine).toBeDefined();
// int64 fields (score, rank) serialize as JSON strings via protojson — coerce to number.
expect(Number(mine?.score)).toBe(score);
expect(Number(mine?.rank ?? 0)).toBeGreaterThanOrEqual(1);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { freshPlayer } from './_setup/harness.js';
describe('e2e-prod: player', () => {
it('device login yields a profile with a player id and wallets array', async () => {
const client = await freshPlayer();
const profile = await client.player.reload();
expect(profile.player?.id).toBeTruthy();
expect(Array.isArray(profile.wallets)).toBe(true);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { adminApi, freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.js';
// Exercises the (newly implemented) wallet + inventory + purchase backend end-to-end:
// admin grant -> debit on buy -> inventory credit -> idempotency -> max_purchases -> insufficient funds.
interface AdminWalletDetails {
currencyCode: string;
balance: number;
}
interface AdminInventoryItem {
itemId: string;
amount: number;
}
interface AdminPlayerDetails {
wallets?: AdminWalletDetails[];
inventory?: AdminInventoryItem[];
}
describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
async function resolvePaidOffer(client: RudderClient): Promise<OfferHandle> {
const stores = await withReadRetry(
() => client.stores.reload(),
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
);
const store = stores.find((s) => s.name === artifact.store.name)!;
return store.offers.find((o) => o.name === artifact.store.paidOfferName)!;
}
async function balance(client: RudderClient, currency: string): Promise<number> {
const profile = await client.player.reload();
// int64 fields serialize as JSON strings via protojson — coerce to number.
return Number(profile.wallets?.find((w) => w.currency === currency)?.balance ?? 0);
}
it('grants currency, debits on buy, credits inventory, is idempotent, and enforces the purchase limit', async () => {
const client = await freshPlayer(artifact);
const playerId = (await client.player.reload()).player!.id!;
const api = adminApi(artifact);
const currency = artifact.store.paidPrice.currency;
const price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client);
// 1. Grant currency via the new admin endpoint.
await api.post(`/game/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, {
currencyCode: currency,
amount: 1000,
reason: 'e2e grant',
});
const granted = await withReadRetry(
() => balance(client, currency),
(b) => b === 1000,
);
expect(granted).toBe(1000);
// 2. First paid buy: success + wallet debited.
const buy1 = await offer.buy();
expect(buy1.success).toBe(true);
expect(buy1.purchaseId).toBeTruthy();
expect(await balance(client, currency)).toBe(1000 - price);
// 3. Inventory credited (verified via admin player details).
const details = await api.get<AdminPlayerDetails>(
`/game/v1/projects/${artifact.projectId}/players/${playerId}`,
);
const inv = details.inventory?.find((i) => i.itemId === artifact.item.id);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
// 4. Idempotency: the same key twice charges once.
const key = crypto.randomUUID();
const a = await offer.buy({ idempotencyKey: key });
const b = await offer.buy({ idempotencyKey: key });
expect(a.success).toBe(true);
expect(b.success).toBe(true);
expect(await balance(client, currency)).toBe(1000 - price * 2);
// 5. max_purchases (2) reached -> a new distinct buy fails (no charge).
const overLimit = await offer.buy();
expect(overLimit.success).toBe(false);
expect(overLimit.error ?? '').toContain('limit');
expect(await balance(client, currency)).toBe(1000 - price * 2);
});
it('rejects a paid purchase with insufficient funds', async () => {
const poor = await freshPlayer(artifact);
const offer = await resolvePaidOffer(poor);
const res = await offer.buy();
expect(res.success).toBe(false);
expect(res.error ?? '').toContain('insufficient');
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
interface GameRemoteConfig extends Record<string, unknown> {
max_energy: number;
spawn_rate: number;
feature_x: boolean;
welcome_text: string;
shop_layout: Record<string, unknown>;
}
describe('e2e-prod: remoteConfig', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('loads typed configs matching the seeded values', async () => {
const rc = artifact.remoteConfigs;
const client = await withReadRetry(
() => freshPlayer<GameRemoteConfig>(artifact),
(candidate) => candidate.remoteConfig.get('max_energy', 0) === rc.maxEnergy,
);
expect(client.remoteConfig.status).toBe('ready');
expect(client.remoteConfig.get('max_energy', 0)).toBe(rc.maxEnergy);
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(rc.spawnRate);
expect(client.remoteConfig.get('feature_x', false)).toBe(rc.featureX);
expect(client.remoteConfig.get('welcome_text', '')).toBe(rc.welcomeText);
expect(client.remoteConfig.get('shop_layout', {} as Record<string, unknown>)).toEqual(rc.shopLayout);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { loadArtifact, makeProdClient } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.js';
describe('e2e-prod: scenarios', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('runs onboarding: notification -> rc override -> store -> boundary callback -> notification', async () => {
const notifications: NotificationEffect[] = [];
let storeOffer: StoreOfferEffect | undefined;
let runCompleted = false;
const client = makeProdClient(artifact, {
planStateStore: null,
loginEvent: artifact.scenario.event,
});
client.effects.onNotification((effect) => { notifications.push(effect); });
client.effects.onStoreOffer((effect) => {
storeOffer = effect;
});
const runtime = await getScenarioRuntime(client);
runtime.onRunCompleted = () => {
runCompleted = true;
};
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
// First notification dispatched.
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done();
// remote_config_override applies, then the store offer surfaces.
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
artifact.remoteConfigs.spawnRateOverride,
);
// Buy crosses the server boundary (POST /sdk/v1/scenarios/callback) -> second notification.
const offer = storeOffer!.offers.find((o) => o.name === artifact.store.paidOfferName)!;
await storeOffer!.buy(offer);
await vi.waitFor(() => expect(notifications.length).toBe(2), { timeout: 15_000 });
await notifications[1].done();
await vi.waitFor(() => expect(runtime.isRunning).toBe(false), { timeout: 10_000 });
expect(runCompleted).toBe(true);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { freshPlayer } from './_setup/harness.js';
describe('e2e-prod: storage', () => {
it('save -> get -> delete round-trip', async () => {
const client = await freshPlayer();
const payload = JSON.stringify({ level: 7 });
const item = { id: 'slot_1', type: 'savegame', data: payload };
await client.storage.save([item]);
// The server may assign its own item id, so match on type + data, not the client id.
// v0.2 fetches the whole storage; filtering by type is client-side.
const got = await client.storage.reload();
expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true);
await client.storage.delete('savegame');
const after = await client.storage.reload();
expect(after.items?.some((i) => i.data === payload)).toBeFalsy();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
describe('e2e-prod: stores', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('lists the seeded store with its free + paid offers', async () => {
const client = await freshPlayer(artifact);
const stores = await withReadRetry(
() => client.stores.reload(),
(r) => r.length > 0,
);
const store = stores.find((s) => s.name === artifact.store.name);
expect(store).toBeDefined();
const offerNames = store!.offers.map((o) => o.name);
expect(offerNames).toContain(artifact.store.freeOfferName);
expect(offerNames).toContain(artifact.store.paidOfferName);
});
it('buys the free offer successfully', async () => {
const client = await freshPlayer(artifact);
const stores = await withReadRetry(
() => client.stores.reload(),
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
);
const store = stores.find((s) => s.name === artifact.store.name)!;
const free = store.offers.find((o) => o.name === artifact.store.freeOfferName)!;
const res = await free.buy();
expect(res.success).toBe(true);
expect(res.purchaseId).toBeTruthy();
});
});
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import type { StoreOfferEffect } from '../../src/index.js';
import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js';
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null },
});
}
/**
* An offer whose `onPurchase` continuation lives server-side (boundary node):
* the client doesn't hold the post-purchase DAG — it must call back so the
* server can validate the transaction and decide what happens next.
*
* `onDecline`, by contrast, is a plain local edge handled entirely on-device.
*/
function offerWithBoundary() {
return plan('offer_flow')
.runId('offer_flow-run')
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy the starter pack!' })
.node('consolation', 'notification', { message: 'Maybe next time!' })
.edge('offer', 'onDecline', 'consolation')
.boundary('offer', 'onPurchase') // server owns what comes after a purchase
.build();
}
/** What the server returns from the callback — a fresh plan = a new run. */
function rewardContinuation() {
return plan('reward_flow')
.runId('reward_flow-run')
.node('reward', 'notification', { message: 'Reward granted: 500 gems!' })
.build();
}
describe('E2E: boundary nodes (server-side continuation)', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
stubDeterministicUuid();
gateway = createFakeGateway({
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
}],
});
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
gateway.onEvent('player_login', offerWithBoundary());
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
const messages: string[] = [];
const completed: PlanRun[] = [];
let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client);
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
runtime.onRunCompleted = (r) => completed.push(r);
const token = (await client.auth.loginWithDevice()).accessToken;
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]);
// The callback hit the gateway with the boundary's source node/handle + token.
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' });
expect(callback?.authToken).toBe(token);
// The server-supplied continuation ran (as a new run), not the local edge.
expect(messages).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('Maybe next time!');
// The original run finished; the continuation ('reward_flow') is now active.
expect(completed.map((r) => r.scenarioId)).toContain('offer_flow');
expect(runtime.activeRuns.map((r) => r.scenarioId)).toEqual(['reward_flow']);
});
it('decline stays local → no callback, local edge is followed', async () => {
gateway.onEvent('player_login', offerWithBoundary());
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss();
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(false);
expect(messages).toEqual(['Maybe next time!']);
});
it('a boundary takes precedence over a local edge on the SAME handle', async () => {
// `offer` has BOTH a boundary AND a local edge on onPurchase.
const conflicting = plan('offer_flow')
.runId('offer_flow-conflict')
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy!' })
.node('local_next', 'notification', { message: 'LOCAL branch' })
.edge('offer', 'onPurchase', 'local_next')
.boundary('offer', 'onPurchase')
.build();
gateway.onEvent('player_login', conflicting);
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]);
// Only the server continuation ran; the local edge was suppressed.
expect(messages).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('LOCAL branch');
});
it('a failing callback does not fail or crash the run', async () => {
gateway.onEvent('player_login', offerWithBoundary());
gateway.onCallbackError('offer', 'onPurchase', 500);
const failures: ScenarioRunFailedEvent[] = [];
const completed: PlanRun[] = [];
let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client);
client.effects.onStoreOffer((effect) => { offer = effect; });
runtime.onRunFailed = (e) => failures.push(e);
runtime.onRunCompleted = (r) => completed.push(r);
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
// Must not throw despite the 500 from the callback endpoint.
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
expect(failures).toHaveLength(0); // 500 is transient — no terminal failure
// The run stays pending because the boundary call failed transiently.
// On reconnection the consumer can retry the purchase completion.
expect(runtime.isRunning).toBe(true);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
const MINUTE = 60_000;
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null }, // exercised separately in the persistence test
});
}
/**
* The offer scenario both branches share:
*
* login → wait 1m → offer ──onDecline──→ wait 1m → "still available"
* └─onPurchase─→ "thanks for your purchase"
*/
function offerScenario() {
return plan('offer_flow')
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
.node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' })
.node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' })
.node('reminder', 'notification', { message: 'Your offer is still available!' })
.node('thanks', 'notification', { message: 'Thanks for your purchase!' })
.edge('wait_intro', 'onComplete', 'offer')
.edge('offer', 'onDecline', 'wait_reminder')
.edge('wait_reminder', 'onComplete', 'reminder')
.edge('offer', 'onPurchase', 'thanks')
.build();
}
describe('E2E: player offer journey', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
gateway = createFakeGateway({
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
}],
});
gateway.install();
client = makeClient();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('authenticates the device and persists tokens for later calls', async () => {
const res = await client.auth.loginWithDevice({ region: 'eu', language: 'en' });
expect(res.accessToken).toBeTruthy();
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
// The login request carried the project key + a device id.
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device');
expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
// The post-login runtime trigger carries the bearer token.
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
expect(trigger?.authToken).toBe(res.accessToken);
});
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
gateway.onEvent('player_login', offerScenario());
const notifications: NotificationEffect[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { notifications.push(effect); });
await client.auth.loginWithDevice();
// Immediately after login the player is just waiting — no offer yet.
expect(offer).toBeUndefined();
// The offer must NOT appear before the full minute has elapsed...
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(offer).toBeUndefined();
// ...and surfaces exactly when the minute is up.
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
// Player declines → the DAG moves to the reminder wait, no notification yet.
await offer!.dismiss();
expect(notifications).toHaveLength(0);
// The reminder also honours the full minute, not a moment sooner.
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_000);
expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Your offer is still available!');
});
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
gateway.onEvent('player_login', offerScenario());
let offer: StoreOfferEffect | undefined;
const notifications: NotificationEffect[] = [];
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { notifications.push(effect); });
const accessToken = (await client.auth.loginWithDevice()).accessToken;
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
// The game buys a selected offer; the session advances only after success.
const selectedOffer = offer!.offers[0];
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
expect(purchase.success).toBe(true);
// The purchase hit the gateway with idempotency key + bearer token.
expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]);
// The scenario took the onPurchase branch → "thanks", and the run finished.
expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Thanks for your purchase!');
});
it('declining does NOT take the purchase branch (handles are isolated)', async () => {
gateway.onEvent('player_login', offerScenario());
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
await client.auth.loginWithDevice();
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss();
await vi.advanceTimersByTimeAsync(MINUTE);
expect(messages).toEqual(['Your offer is still available!']);
expect(messages).not.toContain('Thanks for your purchase!');
expect(gateway.purchases).toHaveLength(0);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { createMemoryPlanStore, stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import type { StoreSession, WaitSession } from '../../src/scenario/engine/sessions.js';
const MINUTE = 60_000;
describe('E2E: scenario state survives a reload', () => {
let gateway: FakeGateway;
// Shared backing store mimics IndexedDB persisting across page loads.
const backing = { value: null as string | null };
function clientWithSharedStore(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: createMemoryPlanStore(backing), loginEvent: null },
});
}
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
backing.value = null;
gateway = createFakeGateway();
gateway.onEvent(
'session_start',
plan('offer_flow')
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
.node('offer', 'store', { message: 'Limited starter pack!' })
.edge('wait_intro', 'onComplete', 'offer')
.build(),
);
gateway.install();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('a wait started before reload resumes and fires the offer after reload', async () => {
// --- Session 1: trigger the scenario, then "close the tab" mid-wait ---
const client1 = clientWithSharedStore();
await client1.auth.loginWithDevice();
const runtime1 = await getScenarioRuntime(client1);
await runtime1.send('session_start');
expect(runtime1.isRunning).toBe(true);
expect(backing.value).toBeTruthy(); // run was persisted
// --- Session 2: fresh client, same persisted state (page reload) ---
const client2 = clientWithSharedStore();
const runtime2 = await getScenarioRuntime(client2);
const resumedWaits: WaitSession[] = [];
let offer: StoreSession | undefined;
runtime2.onWait = (s) => resumedWaits.push(s);
runtime2.onStore = (s) => { offer = s; };
await client2.auth.loginWithDevice();
// The wait node was rehydrated and re-dispatched.
expect(runtime2.isRunning).toBe(true);
expect(resumedWaits).toHaveLength(1);
expect(offer).toBeUndefined();
// The remaining wait time still elapses → the offer surfaces on the new client.
await vi.advanceTimersByTimeAsync(MINUTE);
expect(offer).toBeDefined();
expect(offer!.get('message', '')).toBe('Limited starter pack!');
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js';
interface GameRemoteConfig extends Record<string, unknown> {
max_energy: number;
drop_rate: number;
pvp_enabled: boolean;
welcome_text: string;
boss: { hp: number; name: string };
legacy: string;
missing_declared: string;
}
function cfg(key: string, value: string, valueType: string, active = true): RemoteConfig {
return { key, value, valueType, active };
}
function makeClient(): RudderClient<GameRemoteConfig> {
return new RudderClient<GameRemoteConfig>({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null },
});
}
describe('E2E: remote config', () => {
let gateway: FakeGateway;
let client: RudderClient<GameRemoteConfig>;
beforeEach(() => {
stubDeterministicUuid();
gateway = createFakeGateway({
remoteConfigs: {
max_energy: cfg('max_energy', '50', 'int'),
drop_rate: cfg('drop_rate', '0.25', 'float'),
pvp_enabled: cfg('pvp_enabled', 'true', 'bool'),
welcome_text: cfg('welcome_text', 'Hello!', 'string'),
boss: cfg('boss', '{"hp":1000,"name":"Drake"}', 'json'),
legacy: cfg('legacy', 'old', 'string', false), // inactive — must be excluded
},
});
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('loads configs and reads them back with type coercion', async () => {
await client.auth.loginWithDevice();
expect(client.remoteConfig.status).toBe('ready');
expect(client.remoteConfig.get('max_energy', 0)).toBe(50);
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.25);
expect(client.remoteConfig.get('pvp_enabled', false)).toBe(true);
expect(client.remoteConfig.get('welcome_text', '')).toBe('Hello!');
expect(client.remoteConfig.get('boss', { hp: 0, name: '' })).toEqual({
hp: 1000,
name: 'Drake',
});
});
it('inactive configs are not exposed; missing keys fall back to default', async () => {
await client.auth.loginWithDevice();
expect(client.remoteConfig.get('legacy', 'DEFAULT')).toBe('DEFAULT');
expect(client.remoteConfig.get('missing_declared', 'fallback')).toBe('fallback');
});
it('get() before login returns the default (no throw)', () => {
expect(client.remoteConfig.status).toBe('idle');
expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
});
it('a remote_config_override scenario node patches the live cache', async () => {
client = new RudderClient<GameRemoteConfig>({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null },
});
gateway.onEvent(
'player_login',
plan('boost')
.node('override', 'remote_config_override', {
patches: [{ path: 'drop_rate', valueType: 'float', value: '0.9' }],
})
.build(),
);
await client.auth.loginWithDevice();
// The override is reflected immediately, without a reload.
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.9);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null },
});
}
describe('E2E: player storage', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
gateway = createFakeGateway();
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('saves items then reads them back (round-trip through the gateway)', async () => {
await client.auth.loginWithDevice();
await client.storage.save([
{ id: 'save_1', type: 'progress', data: JSON.stringify({ level: 7, gold: 1200 }) },
{ id: 'settings', type: 'prefs', data: JSON.stringify({ sound: false }) },
]);
const all = await client.storage.reload();
expect(all.items).toHaveLength(2);
const progress = all.items!.find((i) => i.id === 'save_1');
expect(JSON.parse(progress!.data!)).toEqual({ level: 7, gold: 1200 });
});
it('filters by type', async () => {
await client.auth.loginWithDevice();
await client.storage.save([
{ id: 'a', type: 'progress', data: '{}' },
{ id: 'b', type: 'prefs', data: '{}' },
]);
// v0.2 fetches the whole storage; filtering by type is client-side.
const all = await client.storage.reload();
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
expect(prefs).toHaveLength(1);
expect(prefs[0].id).toBe('b');
// The read went out as a storage fetch with limit as a query param, not a path segment.
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
expect(getReq?.query.get('limit')).toBe('100');
});
it('deletes a type and the items are gone', async () => {
await client.auth.loginWithDevice();
await client.storage.save([
{ id: 'a', type: 'progress', data: '{}' },
{ id: 'b', type: 'prefs', data: '{}' },
]);
await client.storage.delete('progress');
const remaining = await client.storage.reload();
expect(remaining.items?.map((i) => i.id)).toEqual(['b']);
});
it('storage writes carry the auth token', async () => {
const token = (await client.auth.loginWithDevice()).accessToken;
await client.storage.save([{ id: 'a', type: 't', data: '{}' }]);
const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage');
expect(put?.authToken).toBe(token);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { TokenStore } from '../../src/token/TokenStore.js';
/** In-memory token store for testing. */
export function createFakeTokenStore(): TokenStore {
let access: string | null = null;
let refresh: string | null = null;
return {
getAccessToken: () => access,
getRefreshToken: () => refresh,
saveTokens: (a, r) => {
access = a;
refresh = r;
},
clear: () => {
access = null;
refresh = null;
},
};
}
+13
View File
@@ -0,0 +1,13 @@
import { RudderClient } from '../../src/client/RudderClient.js';
import type { RudderClientOptions } from '../../src/client/RudderClientOptions.js';
import { createFakeTokenStore } from './FakeTokenStore.js';
/** Creates a RudderClient with test-friendly defaults and a fake token store. */
export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project-key',
tokenStore: createFakeTokenStore(),
...overrides,
});
}
+241
View File
@@ -0,0 +1,241 @@
import { vi } from 'vitest';
import type { ExecutionPlan } from '../../src/generated/common.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js';
import type { Store } from '../../src/generated/stores.js';
import type { StorageItem } from '../../src/generated/storage.js';
import type {
ClaimQuestResponse,
ListQuestsResponse,
} from '../../src/generated/quests.js';
/**
* In-process fake of the LiveOps API gateway.
*
* Stubs the global `fetch` and routes requests the same way the real gateway
* does — so tests exercise the *real* SDK transport, auth-header injection,
* scenario engine and DAG traversal, just against deterministic in-memory state
* instead of a live backend.
*
* Seed state up front (scenarios per event, remote configs, stores), then drive
* the SDK through a journey and assert on `recorded` requests / server state.
*/
export interface RecordedRequest {
method: string;
path: string;
query: URLSearchParams;
body: unknown;
authToken: string | null;
}
export interface FakeGatewayState {
/** Plans returned by POST /scenarios/trigger, keyed by event name. */
scenarios: Map<string, ExecutionPlan[]>;
/** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */
callbacks: Map<string, ExecutionPlan>;
/** HTTP status to fail a callback with, keyed by `nodeId:handle`. */
callbackErrors: Map<string, number>;
/** Optional response for POST /scenarios/counter. */
counterPlan?: ExecutionPlan;
remoteConfigs: Record<string, RemoteConfig>;
stores: Store[];
quests: ListQuestsResponse;
questClaims: Map<string, ClaimQuestResponse>;
/** Player KV storage, keyed by item id. */
storage: Map<string, StorageItem>;
}
export interface FakeGateway {
state: FakeGatewayState;
recorded: RecordedRequest[];
/** Purchases received, in order. */
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>;
/** Quest claim requests received, in order. */
questClaims: Array<{ questId: string; authToken: string | null }>;
install(): void;
/** Convenience: register the plans returned for a trigger event. */
onEvent(event: string, ...plans: ExecutionPlan[]): void;
/** Convenience: register the plan returned when a boundary handle calls back. */
onCallback(nodeId: string, handle: string, plan: ExecutionPlan): void;
/** Convenience: make a boundary callback fail with an HTTP status (default 500). */
onCallbackError(nodeId: string, handle: string, status?: number): void;
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const noContent = (): Response => new Response(null, { status: 204 });
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = {
scenarios: seed?.scenarios ?? new Map(),
callbacks: seed?.callbacks ?? new Map(),
callbackErrors: seed?.callbackErrors ?? new Map(),
counterPlan: seed?.counterPlan,
remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] },
questClaims: seed?.questClaims ?? new Map(),
storage: seed?.storage ?? new Map(),
};
const recorded: RecordedRequest[] = [];
const purchases: FakeGateway['purchases'] = [];
const questClaims: FakeGateway['questClaims'] = [];
async function handle(req: RecordedRequest): Promise<Response> {
const { method, path, query, body } = req;
// --- Auth ---
if (method === 'POST' && path === '/sdk/v1/authorization/device') {
const b = body as { deviceId?: string };
return json({
accessToken: `access-${b.deviceId ?? 'dev'}`,
refreshToken: `refresh-${b.deviceId ?? 'dev'}`,
});
}
// --- Player ---
if (method === 'GET' && path === '/sdk/v1/player/information') {
return json({ player: null, wallets: [] });
}
// --- Sync (baseline revision poll after login) ---
if (method === 'GET' && path === '/sdk/v1/sync') {
return json({});
}
// --- Catalog ---
if (method === 'GET' && path === '/sdk/v1/catalog') {
return json({ items: [] });
}
// --- Scenarios ---
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
const event = (body as { event?: string }).event ?? '';
return json({ plans: state.scenarios.get(event) ?? [] });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
const b = body as { nodeId?: string; handle?: string };
const key = `${b.nodeId}:${b.handle}`;
const errStatus = state.callbackErrors.get(key);
if (errStatus) return json({ error: 'callback failed' }, errStatus);
const plan = state.callbacks.get(key);
return json(plan ? { plan } : {});
}
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false });
}
// --- Remote config ---
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
return json({ configs: state.remoteConfigs });
}
if (method === 'GET' && path.startsWith('/sdk/v1/remote-configs/')) {
const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length));
const cfg = state.remoteConfigs[key];
return cfg ? json(cfg) : json({ error: 'not found' }, 404);
}
// --- Stores ---
if (method === 'GET' && path === '/sdk/v1/stores') {
return json({ stores: state.stores, total: state.stores.length });
}
const purchaseMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)\/offers\/([^/]+)\/purchase$/);
if (method === 'POST' && purchaseMatch) {
const b = body as { idempotencyKey?: string };
purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]),
offerId: decodeURIComponent(purchaseMatch[2]),
idempotencyKey: b.idempotencyKey ?? '',
authToken: req.authToken,
});
return json({ success: true, purchaseId: `purchase-${purchases.length}` });
}
const storeMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)$/);
if (method === 'GET' && storeMatch) {
const slug = decodeURIComponent(storeMatch[1]);
const store = state.stores.find((s) => s.slug === slug);
return store ? json(store) : json({ error: 'not found' }, 404);
}
// --- Quests ---
if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests);
}
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questId?: string };
const questId = b.questId ?? '';
questClaims.push({ questId, authToken: req.authToken });
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
}
// --- Storage ---
if (path === '/sdk/v1/storage') {
if (method === 'GET') {
const typeFilter = query.get('types');
const items = [...state.storage.values()].filter(
(it) => !typeFilter || it.type === typeFilter,
);
return json({ items, nextCursor: '' });
}
if (method === 'PUT') {
const items = (body as { items?: StorageItem[] }).items ?? [];
for (const item of items) {
if (item.id) state.storage.set(item.id, item);
}
return noContent();
}
if (method === 'DELETE') {
const type = query.get('type');
for (const [id, item] of state.storage) {
if (!type || item.type === type) state.storage.delete(id);
}
return noContent();
}
}
return json({ error: `unhandled route: ${method} ${path}` }, 500);
}
function install(): void {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL, init?: RequestInit): Promise<Response> => {
const url = new URL(typeof input === 'string' ? input : input.toString());
const headers = new Headers(init?.headers);
const auth = headers.get('Authorization');
const req: RecordedRequest = {
method: (init?.method ?? 'GET').toUpperCase(),
path: url.pathname,
query: url.searchParams,
body: init?.body ? JSON.parse(init.body as string) : undefined,
authToken: auth ? auth.replace(/^Bearer\s+/, '') : null,
};
recorded.push(req);
return handle(req);
}),
);
}
return {
state,
recorded,
purchases,
questClaims,
install,
onEvent(event, ...plans) {
state.scenarios.set(event, plans);
},
onCallback(nodeId, handle, plan) {
state.callbacks.set(`${nodeId}:${handle}`, plan);
},
onCallbackError(nodeId, handle, status = 500) {
state.callbackErrors.set(`${nodeId}:${handle}`, status);
},
};
}
+41
View File
@@ -0,0 +1,41 @@
import type { PlanStateStore } from '../../src/scenario/engine/IndexedDbPlanStore.js';
/**
* In-memory PlanStateStore that survives across RudderClient instances — lets a
* test simulate a page reload: drive scenario A on one client, construct a fresh
* client sharing the same `backing`, call `scenarios.restore()`, and assert the
* run resumed mid-DAG.
*/
export function createMemoryPlanStore(backing: { value: string | null } = { value: null }): PlanStateStore {
return {
get state() {
return backing.value;
},
set state(v: string | null) {
backing.value = v;
},
async load() {
/* state already in `backing` */
},
async save(s: string | null) {
backing.value = s;
},
};
}
/** Deterministic, collision-free crypto.randomUUID() stub for scenario run IDs. */
export function stubDeterministicUuid(): void {
let n = 0;
const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto);
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
...existing,
randomUUID: () => {
n += 1;
const hex = n.toString(16).padStart(12, '0');
return `00000000-0000-4000-a000-${hex}` as `${string}-${string}-${string}-${string}-${string}`;
},
},
});
}
+72
View File
@@ -0,0 +1,72 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.js';
/**
* Small fluent builder for ExecutionPlans, so scenario journeys read like the
* DAGs they model rather than walls of object literals.
*
* plan('offer_flow')
* .node('wait1', 'wait', { duration: 1, unit: 'minutes' })
* .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1' })
* .edge('wait1', 'onComplete', 'offer')
* .build();
*
* The first node added becomes the start node unless `.start(id)` is called.
*/
export class PlanBuilder {
private readonly nodes: ExecutionPlanNode[] = [];
private readonly edges: PlanEdge[] = [];
private readonly boundaryNodes: BoundaryNode[] = [];
private startNodeId?: string;
private runIdValue?: string;
constructor(
private readonly scenarioId: string,
private readonly opts: { planId?: string; userId?: string } = {},
) {}
/** Sets a specific run ID (default: auto-derived from scenarioId). */
runId(id: string): this {
this.runIdValue = id;
return this;
}
node(id: string, type: string, data?: Record<string, unknown>): this {
this.nodes.push({ id, type, data: data as ExecutionPlanNode['data'] });
if (!this.startNodeId) this.startNodeId = id;
return this;
}
edge(source: string, sourceHandle: string, target: string): this {
this.edges.push({ id: `e-${source}-${sourceHandle}-${target}`, source, sourceHandle, target });
return this;
}
/** Registers a server-side boundary node (handle that calls back to the gateway). */
boundary(sourceNodeId: string, sourceHandle: string, nodeId = `b-${sourceNodeId}-${sourceHandle}`): this {
this.boundaryNodes.push({ sourceNodeId, sourceHandle, nodeId });
return this;
}
start(id: string): this {
this.startNodeId = id;
return this;
}
build(): ExecutionPlan {
return {
planId: this.opts.planId ?? `${this.scenarioId}-plan`,
scenarioId: this.scenarioId,
userId: this.opts.userId ?? 'player-1',
startNodeId: this.startNodeId,
runId: this.runIdValue ?? `${this.scenarioId}-run`,
nodes: this.nodes,
edges: this.edges,
boundaryNodes: this.boundaryNodes,
context: undefined,
};
}
}
export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder {
return new PlanBuilder(scenarioId, opts);
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"types": ["node"],
"lib": ["ES2023", "DOM", "DOM.Iterable"]
},
"include": ["src", "test"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
target: 'es2022',
dts: true,
clean: true,
// Code-split the lazily-imported scenario engine out of the initial chunk.
splitting: true,
treeshake: true,
platform: 'browser',
outDir: 'dist',
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['test/**/*.test.ts'],
// The prod e2e suite has its own config (vitest.e2e-prod.config.ts) and hits a real API.
exclude: ['**/node_modules/**', '**/dist/**', 'test/e2e-prod/**'],
setupFiles: [],
},
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
// Production e2e: drives the real SDK against a seeded project on api.rudder.build.
// Runs in Node (real fetch, no jsdom). globalSetup seeds + tears down the project.
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['test/e2e-prod/**/*.e2e.test.ts'],
globalSetup: ['test/e2e-prod/_setup/globalSetup.ts'],
testTimeout: 60_000,
hookTimeout: 120_000,
retry: 1,
fileParallelism: false,
pool: 'forks',
},
});