@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user