Files
rudder-js-sdk/src/client/RudderClient.ts
T

263 lines
9.1 KiB
TypeScript
Raw Normal View History

2026-08-12 14:02:25 +03:00
/**
* 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();
}