@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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('&')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user