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
+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('&')}`;
}