feat: modules and resources domains, drop removed domains, regenerated models

This commit is contained in:
edmand46
2026-09-17 12:08:03 +03:00
parent f45261439f
commit 6f37b00fb4
71 changed files with 516 additions and 4184 deletions
+9 -2
View File
@@ -7,7 +7,6 @@
*/
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 {
@@ -72,7 +71,7 @@ export interface LinkWithCustomOptions {
provider?: string;
}
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
export class AuthService {
private readonly listeners = new Set<AuthStateListener>();
private state: AuthState;
@@ -118,6 +117,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region,
language,
nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
});
this.ctx.options.tokenStore.saveTokens(
@@ -145,6 +146,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region,
language,
nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
});
this.ctx.options.tokenStore.saveTokens(
@@ -169,6 +172,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region,
language,
nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
});
this.ctx.options.tokenStore.saveTokens(
@@ -193,6 +198,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region,
language,
nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
});
this.ctx.options.tokenStore.saveTokens(
-46
View File
@@ -1,46 +0,0 @@
/**
* 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 `scenarioSlug` + `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(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioSlug, 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);
}
}
+20 -112
View File
@@ -1,16 +1,12 @@
/**
* 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.
* Composes the kernel domains (auth, player, catalog, storage, modules,
* resources) and the HTTP transport every service uses to reach the API.
*
* 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 effects client is loaded lazily on first use — a client that
* never logs in never pays for the scenario machinery.
* domain, so they depend on that narrow surface rather than on the client
* (which keeps the module graph acyclic).
*/
import type {
@@ -22,45 +18,32 @@ 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 { 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 { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
import { StoresDomain } from '../domains/StoresDomain.js';
import { ModulesDomain } from '../domains/ModulesDomain.js';
import { ResourcesDomain, type ResourceHandle } from '../domains/ResourcesDomain.js';
type ManagedDomain = { invalidate(): void; reset(): void };
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
export class RudderClient {
public readonly options: ResolvedRudderClientOptions;
public readonly auth: AuthService<TConfig>;
public readonly leaderboards: LeaderboardsService;
public readonly battlePass: BattlePassService;
public readonly quests: QuestsService;
public readonly auth: AuthService;
public readonly effects: Effects;
public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain;
public readonly inventory: InventoryDomain;
public readonly remoteConfig: ConfigDomain<TConfig>;
public readonly storage: StorageDomain;
public readonly projectStorage: ProjectStorageDomain;
public readonly stores: StoresDomain;
public readonly modules: ModulesDomain;
private readonly resourcesDomain: ResourcesDomain;
private readonly effectsCenter: EffectsCenter;
private scenarioRuntime: ScenarioService | null = null;
private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
private readonly syncEngine: SyncEngine;
private readonly loginEvent: string | null;
private readonly ctx: RudderContext;
private readonly transportContext: RequestContext;
private runtimeStarted = false;
@@ -78,13 +61,12 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
}
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,
appVersion: this.options.appVersion,
refreshState: {},
onAuthFailure: () => {
this.auth.notifySessionExpired();
@@ -105,29 +87,18 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
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.projectStorage = new ProjectStorageDomain(ctx);
this.stores = new StoresDomain(ctx, () => {
this.player.invalidate();
this.inventory.invalidate();
this.stores.invalidate();
});
this.modules = new ModulesDomain(ctx);
this.resourcesDomain = new ResourcesDomain(ctx);
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.projectStorage,
this.stores,
]);
this.auth = new AuthService<TConfig>(
this.auth = new AuthService(
ctx,
() => this.startRuntimeAfterLogin(),
() => this.stopRuntimeAfterLogout(),
@@ -136,81 +107,33 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
dispose(): void {
this.syncEngine.stop();
this.scenarioRuntime?.clear();
this.resetAll();
this.runtimeStarted = false;
}
resources(slug: string): ResourceHandle {
return this.resourcesDomain.of(slug);
}
/** @internal */
async startRuntimeAfterLogin(): Promise<void> {
const runtimePromise = this.ensureScenarioRuntime();
if (this.runtimeStarted) {
this.scenarioRuntime?.clear();
this.invalidateAll();
}
this.runtimeStarted = true;
await Promise.all([
this.remoteConfig.load(),
this.player.load(),
this.stores.load(),
this.catalog.load(),
]);
const runtime = await runtimePromise;
await runtime.start();
if (this.loginEvent) {
await this.sendRuntimeEvent(runtime, this.loginEvent);
}
await Promise.all([this.player.load(), this.catalog.load()]);
this.syncEngine.start();
}
private ensureScenarioRuntime(): Promise<ScenarioService> {
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
return this.scenarioRuntimePromise;
}
private async createScenarioRuntime(): Promise<ScenarioService> {
const { ScenarioService } = await import('../scenario/ScenarioService.js');
this.scenarioRuntime = new ScenarioService(
this.ctx,
{
player: this.player,
inventory: this.inventory,
stores: this.stores,
},
this.battlePass,
);
return this.scenarioRuntime;
}
private async sendRuntimeEvent(
runtime: ScenarioService,
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.projectStorage,
this.stores,
];
return [this.player, this.catalog, this.storage, this.projectStorage];
}
private invalidateAll(): void {
@@ -221,18 +144,3 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
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 effects client on first call.
*
* @internal
*/
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
client: RudderClient<TConfig>,
): Promise<ScenarioService> {
return (
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
).ensureScenarioRuntime();
}
+6 -7
View File
@@ -1,9 +1,5 @@
import type { TokenStore } from '../token/TokenStore.js';
export interface RudderRuntimeOptions {
loginEvent?: string | null;
}
export interface RudderClientOptions {
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
baseUrl: string;
@@ -18,6 +14,12 @@ export interface RudderClientOptions {
*/
tokenStore?: TokenStore;
/**
* Game build version sent as `appVersion` on every login and token refresh
* (used by segments). Omitted when not set.
*/
appVersion?: string;
/** Per-request timeout in milliseconds (default: 10000). */
requestTimeoutMs?: number;
@@ -29,9 +31,6 @@ export interface RudderClientOptions {
* Defaults to `console.error`.
*/
onEffectError?: (error: unknown) => void;
/** Advanced runtime knobs (login event). */
runtime?: RudderRuntimeOptions;
}
/** Client options with all defaults resolved — what `client.options` exposes. */
+4 -6
View File
@@ -3,15 +3,13 @@
*
* 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
* The machine-readable `code` is the server-provided code (open-ended: platform
* codes and module business error codes) or an SDK-local code
* (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 carried by SDK errors: a server code or an SDK-local code. */
export type RudderErrorCodeLike = string;
/** Code for client-side configuration validation failures. */
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
+9 -9
View File
@@ -1,22 +1,22 @@
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';
import type { CatalogCurrency } from '../generated/catalog.js';
/**
* CatalogDomain — the item catalog, keyed by slug. Synced under revision
* key `catalog`. Feeds the merged {@link InventoryDomain} view.
* CatalogDomain — currency definitions, keyed by slug. Synced under revision
* key `catalog`.
*/
export class CatalogDomain extends SyncedState<Map<string, CatalogItem>> {
export class CatalogDomain extends SyncedState<Map<string, CatalogCurrency>> {
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);
const response = await api.listCatalogCurrencies(ctx);
const map = new Map<string, CatalogCurrency>();
for (const currency of response.currencies ?? []) {
if (!currency.slug) continue;
map.set(currency.slug, currency);
}
return map;
});
-33
View File
@@ -1,33 +0,0 @@
import { RemoteConfigState, type RemoteConfigShape } from '../state/RemoteConfigState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { RemoteConfig } from '../generated/remote-config.js';
/**
* ConfigDomain — typed remote configuration as an observable entity. Synced
* under revision key `config`. Extends {@link RemoteConfigState}, which adds
* typed `get()` and scenario override support.
*/
export class ConfigDomain<
TConfig extends RemoteConfigShape = RemoteConfigShape,
> extends RemoteConfigState<TConfig> {
readonly syncKey = 'config';
constructor(ctx: RudderContext) {
super(async () => {
const response = await api.listSdkRemoteConfigs(ctx);
const map = new Map<string, RemoteConfig>();
if (response.configs) {
for (const [key, config] of Object.entries(response.configs)) {
// The server already filters inactive configs out (the game cache skips
// !active) and may omit `active` on the wire, so keep anything that is
// not explicitly inactive instead of dropping configs that carry no flag.
if (config && config.active !== false) {
map.set(config.key ?? key, config);
}
}
}
return map;
});
}
}
-31
View File
@@ -1,31 +0,0 @@
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();
});
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { CallModuleFunctionClientResponse } from '../generated/modules.js';
export class ModulesDomain {
constructor(private readonly ctx: RudderContext) {}
call(module: string, fn: string, args?: unknown): Promise<CallModuleFunctionClientResponse> {
return api.callModuleFunction(this.ctx, { module, fn }, { args });
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type {
CreateResourceRowRequest,
ListResourceRowsResponse,
ResourceRow,
UpdateResourceRowRequest,
} from '../generated/resources.js';
export class ResourceHandle {
constructor(
private readonly ctx: RudderContext,
private readonly slug: string,
) {}
list(): Promise<ListResourceRowsResponse> {
return api.listResourceRows(this.ctx, { slug: this.slug });
}
get(id: string): Promise<ResourceRow> {
return api.getResourceRow(this.ctx, { slug: this.slug, id });
}
create(body: CreateResourceRowRequest): Promise<ResourceRow> {
return api.createResourceRow(this.ctx, { slug: this.slug }, body);
}
update(id: string, body: UpdateResourceRowRequest): Promise<ResourceRow> {
return api.updateResourceRow(this.ctx, { slug: this.slug, id }, body);
}
delete(id: string): Promise<void> {
return api.deleteResourceRow(this.ctx, { slug: this.slug, id });
}
}
export class ResourcesDomain {
constructor(private readonly ctx: RudderContext) {}
of(slug: string): ResourceHandle {
return new ResourceHandle(this.ctx, slug);
}
}
-62
View File
@@ -1,62 +0,0 @@
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, offerSlug, 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,
offerSlug: string,
options: BuyOptions = {},
): Promise<PurchaseOfferResponse> => {
const response = await api.purchaseOffer(
ctx,
{ storeSlug, offerSlug },
{
storeSlug,
offerSlug,
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);
}
}
-116
View File
@@ -1,12 +1,3 @@
import type { PurchaseOfferResponse } from '../generated/stores.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
GetBattlePassProgressResponse,
PurchaseBattlePassPremiumResponse,
} from '../generated/battlepass.js';
export type EffectUnsubscribe = () => void;
export interface NotificationEffect {
@@ -15,25 +6,6 @@ export interface NotificationEffect {
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>;
claim(): Promise<void>;
/** @deprecated Use {@link LeaderboardEffect.claim}. Removed in the next SDK version. */
rewardClaimed(): Promise<void>;
}
export interface ConfigChangedEffect {
readonly key: string;
}
export interface WaitEffect {
readonly deadlineUtc: Date;
}
@@ -50,52 +22,20 @@ export interface ScenarioFailedEffect {
readonly error: Error;
}
export interface QuestEffect {
readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>;
}
export interface BattlePassEffect {
getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
levelUp(): Promise<void>;
end(): Promise<void>;
}
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) =>
@@ -106,18 +46,6 @@ export class EffectsCenter implements Effects {
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);
}
@@ -130,40 +58,11 @@ export class EffectsCenter implements Effects {
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(effect: NotificationEffect): void {
this.emit(this.notificationHandlers, effect);
}
/** @internal */
emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
Promise.resolve(effect)
.then((value) => this.emit(this.storeOfferHandlers, value))
.catch((error) => this.onError(error));
}
/** @internal */
emitLeaderboard(effect: LeaderboardEffect): void {
this.emit(this.leaderboardHandlers, effect);
}
/** @internal */
emitConfigChanged(key: string): void {
this.emit(this.configChangedHandlers, { key });
}
/** @internal */
emitWait(effect: WaitEffect): void {
this.emit(this.waitHandlers, effect);
@@ -179,21 +78,6 @@ export class EffectsCenter implements Effects {
this.emit(this.scenarioFailedHandlers, effect);
}
/** @internal */
emitQuest(effect: QuestEffect): void {
this.emit(this.questHandlers, effect);
}
/** @internal */
emitBattlePass(effect: BattlePassEffect): void {
this.emit(this.battlePassHandlers, effect);
}
/** @internal */
emitBattlePassLevel(effect: BattlePassLevelEffect): void {
this.emit(this.battlePassLevelHandlers, effect);
}
private addHandler<TEffect>(
handlers: Set<EffectHandler<TEffect>>,
handler: EffectHandler<TEffect>,
+33 -111
View File
@@ -1,17 +1,12 @@
// Code generated by apigen. DO NOT EDIT.
import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, LoginViaAppleRequest, LoginViaAppleResponse, LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, LoginViaGoogleRequest, LoginViaGoogleResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse, UnlinkIdentityRequest } 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 { ListCatalogCurrenciesResponse } from './catalog.js';
import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
import type { PlayerProfile } from './player.js';
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js';
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
import type { HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, ListPendingScenarioEffectsResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
import type { CreateResourceRowRequest, ListResourceRowsResponse, ResourceRow, UpdateResourceRowRequest } from './resources.js';
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
/** The minimal transport contract the generated API functions call through. */
export interface Transport {
@@ -36,23 +31,25 @@ function qs(
/** Typed request functions, one per SDK operation. */
export const api = {
addBattlePassXp: (
callModuleFunction: (
t: Transport,
body: AddBattlePassXpRequest,
): Promise<AddBattlePassXpResponse> =>
t.request<AddBattlePassXpResponse>('POST', '/sdk/v1/battlepass/xp', body),
path: { module: string; fn: string },
body: CallModuleFunctionRequest,
): Promise<CallModuleFunctionClientResponse> =>
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
claimBattlePassReward: (
createResourceRow: (
t: Transport,
body: ClaimBattlePassRewardRequest,
): Promise<ClaimBattlePassRewardResponse> =>
t.request<ClaimBattlePassRewardResponse>('POST', '/sdk/v1/battlepass/claim', body),
path: { slug: string },
body: CreateResourceRowRequest,
): Promise<ResourceRow> =>
t.request<ResourceRow>('POST', `/sdk/v1/resources/${enc(path.slug)}`, body),
claimQuest: (
deleteResourceRow: (
t: Transport,
body: ClaimQuestRequest,
): Promise<ClaimQuestResponse> =>
t.request<ClaimQuestResponse>('POST', '/sdk/v1/quests/claim', body),
path: { slug: string; id: string },
): Promise<void> =>
t.request<void>('DELETE', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
deleteStorage: (
t: Transport,
@@ -60,17 +57,6 @@ export const api = {
): Promise<void> =>
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
getBattlePassProgress: (
t: Transport,
body: GetBattlePassProgressRequest,
): Promise<GetBattlePassProgressResponse> =>
t.request<GetBattlePassProgressResponse>('POST', '/sdk/v1/battlepass/progress', body),
getInventory: (
t: Transport,
): Promise<GetInventoryResponse> =>
t.request<GetInventoryResponse>('GET', '/sdk/v1/inventory'),
getPlayerInformation: (
t: Transport,
): Promise<PlayerProfile> =>
@@ -82,18 +68,11 @@ export const api = {
): Promise<GetProjectStorageResponse> =>
t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
getRanking: (
getResourceRow: (
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)}`),
path: { slug: string; id: string },
): Promise<ResourceRow> =>
t.request<ResourceRow>('GET', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
getRevisions: (
t: Transport,
@@ -106,18 +85,6 @@ export const api = {
): 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)}`),
handleScenarioCallback: (
t: Transport,
body: HandleScenarioCallbackRequest,
): Promise<HandleScenarioCallbackResponse> =>
t.request<HandleScenarioCallbackResponse>('POST', '/sdk/v1/scenarios/callback', body),
linkViaApple: (
t: Transport,
body: LinkViaAppleRequest,
@@ -136,30 +103,16 @@ export const api = {
): Promise<void> =>
t.request<void>('POST', '/sdk/v1/authorization/google/link', body),
listCatalogItems: (
listCatalogCurrencies: (
t: Transport,
): Promise<ListCatalogItemsResponse> =>
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
): Promise<ListCatalogCurrenciesResponse> =>
t.request<ListCatalogCurrenciesResponse>('GET', '/sdk/v1/catalog'),
listPendingScenarioEffects: (
listResourceRows: (
t: Transport,
): Promise<ListPendingScenarioEffectsResponse> =>
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
listQuests: (
t: Transport,
): Promise<ListQuestsResponse> =>
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list'),
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'),
path: { slug: string },
): Promise<ListResourceRowsResponse> =>
t.request<ListResourceRowsResponse>('GET', `/sdk/v1/resources/${enc(path.slug)}`),
loginViaApple: (
t: Transport,
@@ -185,44 +138,12 @@ export const api = {
): Promise<LoginViaGoogleResponse> =>
t.request<LoginViaGoogleResponse>('POST', '/sdk/v1/authorization/google', body),
purchaseBattlePassPremium: (
t: Transport,
body: PurchaseBattlePassPremiumRequest,
): Promise<PurchaseBattlePassPremiumResponse> =>
t.request<PurchaseBattlePassPremiumResponse>('POST', '/sdk/v1/battlepass/premium', body),
purchaseOffer: (
t: Transport,
path: { storeSlug: string; offerSlug: string },
body: PurchaseOfferRequest,
): Promise<PurchaseOfferResponse> =>
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
refreshAccessToken: (
t: Transport,
body: RefreshAccessTokenRequest,
): Promise<RefreshAccessTokenResponse> =>
t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body),
reportQuestProgress: (
t: Transport,
body: ReportQuestProgressRequest,
): Promise<ReportQuestProgressResponse> =>
t.request<ReportQuestProgressResponse>('POST', '/sdk/v1/quests/progress', body),
submitScore: (
t: Transport,
path: { slug: string },
body: SubmitScoreRequest,
): Promise<void> =>
t.request<void>('POST', `/sdk/v1/leaderboards/${enc(path.slug)}/submit-score`, body),
triggerScenario: (
t: Transport,
body: TriggerScenarioRequest,
): Promise<TriggerScenarioResponse> =>
t.request<TriggerScenarioResponse>('POST', '/sdk/v1/scenarios/trigger', body),
unlinkIdentity: (
t: Transport,
body: UnlinkIdentityRequest,
@@ -235,11 +156,12 @@ export const api = {
): Promise<void> =>
t.request<void>('PUT', '/sdk/v1/project-storage', body),
updateScenarioCounter: (
updateResourceRow: (
t: Transport,
body: UpdateScenarioCounterRequest,
): Promise<UpdateScenarioCounterResponse> =>
t.request<UpdateScenarioCounterResponse>('POST', '/sdk/v1/scenarios/counter', body),
path: { slug: string; id: string },
body: UpdateResourceRowRequest,
): Promise<ResourceRow> =>
t.request<ResourceRow>('PATCH', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`, body),
updateStorage: (
t: Transport,
+10
View File
@@ -14,10 +14,12 @@ export interface LinkViaGoogleRequest {
}
export interface LoginViaAppleRequest {
"appVersion"?: string;
"idToken": string;
"key": string;
"language": string;
"nickname"?: string;
"platform"?: string;
"region": string;
}
@@ -27,10 +29,12 @@ export interface LoginViaAppleResponse {
}
export interface LoginViaCustomRequest {
"appVersion"?: string;
"customData"?: { [key: string]: unknown };
"key"?: string;
"language"?: string;
"nickname"?: string;
"platform"?: string;
"provider"?: string;
"region"?: string;
}
@@ -41,10 +45,12 @@ export interface LoginViaCustomResponse {
}
export interface LoginViaDeviceRequest {
"appVersion"?: string;
"deviceId"?: string;
"key"?: string;
"language"?: string;
"nickname"?: string;
"platform"?: string;
"region"?: string;
}
@@ -54,10 +60,12 @@ export interface LoginViaDeviceResponse {
}
export interface LoginViaGoogleRequest {
"appVersion"?: string;
"idToken": string;
"key": string;
"language": string;
"nickname"?: string;
"platform"?: string;
"region": string;
}
@@ -67,6 +75,8 @@ export interface LoginViaGoogleResponse {
}
export interface RefreshAccessTokenRequest {
"appVersion"?: string;
"platform"?: string;
"refreshToken"?: string;
}
-63
View File
@@ -1,63 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
import type { Reward } from './common.js';
export interface AddBattlePassXpRequest {
"amount"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
"source"?: string;
}
export interface AddBattlePassXpResponse {
"level"?: number;
"leveledUp"?: boolean;
"maxLevel"?: boolean;
"xp"?: number;
}
export interface ClaimBattlePassRewardRequest {
"level"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
"track"?: "free" | "premium";
}
export interface ClaimBattlePassRewardResponse {
"alreadyClaimed"?: boolean;
"error"?: string;
"granted"?: Reward[];
"success"?: boolean;
}
export interface ClaimedTier {
"level"?: number;
"track"?: string;
}
export interface GetBattlePassProgressRequest {
"nodeId"?: string;
"scenarioSlug"?: string;
}
export interface GetBattlePassProgressResponse {
"claimedTiers"?: ClaimedTier[];
"level"?: number;
"premiumOwned"?: boolean;
"xp"?: number;
}
export interface PurchaseBattlePassPremiumRequest {
"idempotencyKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface PurchaseBattlePassPremiumResponse {
"error"?: string;
"success"?: boolean;
}
+5 -5
View File
@@ -1,13 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
export interface CatalogItem {
export interface CatalogCurrency {
"data"?: { [key: string]: unknown };
"icon"?: string;
"name"?: string;
"properties"?: { [key: string]: unknown };
"slug"?: string;
"tags"?: string[];
}
export interface ListCatalogItemsResponse {
"items"?: CatalogItem[];
export interface ListCatalogCurrenciesResponse {
"currencies"?: CatalogCurrency[];
}
+5 -7
View File
@@ -1,16 +1,14 @@
// Code generated by apigen. DO NOT EDIT.
import type { ResourceRowUsage } from './resources.js';
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";
"code"?: "forbidden" | "row_referenced";
"error"?: string;
"index"?: number;
"path"?: string;
"playerId"?: string;
"requestId"?: string;
}
export interface Reward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
"usages"?: ResourceRowUsage[];
}
+2 -9
View File
@@ -1,15 +1,8 @@
// 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 type RudderErrorCode = "forbidden" | "row_referenced";
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",
rowReferenced: "row_referenced",
} as const;
+2 -7
View File
@@ -1,17 +1,12 @@
// 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 './modules.js';
export * from './player.js';
export * from './project-storage.js';
export * from './quests.js';
export * from './remote-config.js';
export * from './scenarios.js';
export * from './resources.js';
export * from './storage.js';
export * from './stores.js';
export * from './api.js';
export * from './errors.js';
-14
View File
@@ -1,14 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
export interface GetInventoryResponse {
"items"?: PlayerInventoryItem[];
}
export interface PlayerInventoryItem {
"amount"?: number;
"nameOverride"?: string;
"propertiesOverride"?: { [key: string]: unknown };
"slug"?: string;
"updatedAt"?: string;
}
-19
View File
@@ -1,19 +0,0 @@
// 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;
}
+17
View File
@@ -0,0 +1,17 @@
// Code generated by apigen. DO NOT EDIT.
export interface CallModuleFunctionClientResponse {
"result"?: unknown;
}
export interface CallModuleFunctionRequest {
"args"?: unknown;
"dryRun"?: boolean;
}
export interface ModuleErrorResponse {
"code": string;
"error": string;
"requestId"?: string;
}
-44
View File
@@ -1,44 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
import type { Reward } from './common.js';
export interface ClaimQuestRequest {
"questSlug"?: string;
}
export interface ClaimQuestResponse {
"alreadyClaimed"?: boolean;
"error"?: string;
"granted"?: Reward[];
"success"?: boolean;
}
export interface ListQuestsResponse {
"quests"?: Quest[];
}
export interface Quest {
"name"?: string;
"objectives"?: QuestObjectiveProgress[];
"rewards"?: Reward[];
"slug"?: string;
"status"?: "active" | "claimed" | "completed";
}
export interface QuestObjectiveProgress {
"completed"?: boolean;
"current"?: number;
"metric"?: string;
"objectiveId"?: string;
"target"?: number;
}
export interface ReportQuestProgressRequest {
"amount"?: number;
"metric"?: string;
}
export interface ReportQuestProgressResponse {
"completedQuestSlugs"?: string[];
}
-19
View File
@@ -1,19 +0,0 @@
// 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"?: "bool" | "float" | "int" | "json" | "string";
}
+34
View File
@@ -0,0 +1,34 @@
// Code generated by apigen. DO NOT EDIT.
export interface CreateResourceRowRequest {
"data"?: { [key: string]: unknown };
"id"?: string;
}
export interface ListResourceRowsResponse {
"items"?: ResourceRow[];
"total"?: number;
}
export interface ResourceRow {
"createdAt"?: string;
"data"?: { [key: string]: unknown };
"id"?: string;
"playerId"?: string;
"rank"?: number;
"updatedAt"?: string;
"version"?: number;
}
export interface ResourceRowUsage {
"definitionSlug"?: string;
"field"?: string;
"moduleSlug"?: string;
"rowId"?: string;
}
export interface UpdateResourceRowRequest {
"data"?: { [key: string]: unknown };
"version"?: number;
}
-47
View File
@@ -1,47 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
export interface HandleScenarioCallbackRequest {
"handle"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface HandleScenarioCallbackResponse {
"effect"?: PendingEffect;
}
export interface ListPendingScenarioEffectsResponse {
"effects": PendingEffect[];
}
export interface PendingEffect {
"data": { [key: string]: unknown };
"nodeId": string;
"runId": string;
"scenarioSlug": string;
"type": string;
"waitDeadline"?: string;
}
export interface TriggerScenarioRequest {
"event"?: string;
}
export interface TriggerScenarioResponse {
"effects": PendingEffect[];
}
export interface UpdateScenarioCounterRequest {
"amount"?: number;
"counterKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface UpdateScenarioCounterResponse {
"completed"?: boolean;
"effect"?: PendingEffect;
}
-55
View File
@@ -1,55 +0,0 @@
// 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;
"slug"?: string;
"updatedAt"?: string;
}
export interface OfferContent {
"amount"?: number;
"itemId"?: string;
}
export interface OfferPrice {
"amount"?: number;
"currency"?: string;
}
export interface PurchaseOfferRequest {
"idempotencyKey"?: string;
"offerSlug"?: 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;
}
+13 -49
View File
@@ -16,8 +16,7 @@ export type { RudderErrorCodeLike } from './client/RudderError.js';
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).
// Services — types only; instances live on the client (client.auth).
export type {
AuthService,
AuthState,
@@ -28,10 +27,6 @@ export type {
LoginWithDeviceOptions,
LoginWithGoogleOptions,
} 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';
export * as QuestMetrics from './quests/QuestMetrics.js';
// Observable state primitives
export { SyncedState } from './state/SyncedState.js';
@@ -40,74 +35,43 @@ export type {
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, …).
// (client.player, client.modules, client.resources(slug), …).
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 { ModulesDomain } from './domains/ModulesDomain.js';
export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.js';
export type { StorageDomain } from './domains/StorageDomain.js';
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.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, PlayerProfileIdentity, 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 { CatalogCurrency } from './generated/catalog.js';
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
export type {
ProjectStorageItem,
GetProjectStorageResponse,
ProjectStorageUpdateItem,
} from './generated/project-storage.js';
export type { RemoteConfig } from './generated/remote-config.js';
export type { Reward } from './generated/common.js';
export type {
Quest,
QuestObjectiveProgress,
ClaimQuestResponse,
} from './generated/quests.js';
CallModuleFunctionRequest,
CallModuleFunctionClientResponse,
} from './generated/modules.js';
export type {
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';
ResourceRow,
ListResourceRowsResponse,
CreateResourceRowRequest,
UpdateResourceRowRequest,
} from './generated/resources.js';
-52
View File
@@ -1,52 +0,0 @@
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;
}
}
-19
View File
@@ -1,19 +0,0 @@
/**
* QuestMetrics — builders for the quest metric strings known to the platform.
*
* Purchase metrics are reported automatically server-side by the shop
* purchase fan-out, so these helpers exist mainly to name the format
* instead of hardcoding it. Catalog counter slugs are reported via
* {@link QuestsService.reportProgress}. Custom free-text metrics no longer
* progress quests — they no-op at runtime.
*/
/** Metric for purchasing a store offer; auto-reported on purchase. */
export function purchaseOffer(offerSlug: string): string {
return `purchase.offer:${offerSlug}`;
}
/** Metric for purchasing a catalog item; auto-reported on purchase. */
export function purchaseItem(itemId: string): string {
return `purchase.item:${itemId}`;
}
-32
View File
@@ -1,32 +0,0 @@
/**
* 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(questSlug: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questSlug });
}
/** Reports metric progress; returns ids of quests completed by this report. */
async reportProgress(metric: string, amount: number): Promise<string[]> {
const response = await api.reportQuestProgress(this.ctx, { metric, amount });
return response.completedQuestSlugs ?? [];
}
}
-454
View File
@@ -1,454 +0,0 @@
import type { RudderContext } from '../core/context.js';
import type { ShopHandle, OfferHandle, BuyOptions } from '../state/shops.js';
import type { PurchaseOfferResponse } from '../generated/stores.js';
import { api } from '../generated/api.js';
import type { PendingEffect, TriggerScenarioResponse } from '../generated/scenarios.js';
import { RudderErrorCodes } from '../generated/errors.js';
import {
RudderNetworkError,
RudderHttpError,
} from '../client/RudderError.js';
import type { BattlePassService } from '../battlepass/BattlePassService.js';
export interface ScenarioDomains {
readonly player: { invalidate(): void };
readonly inventory: { invalidate(): void };
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
}
const DEFAULT_PENDING_INTERVAL_MS = 30_000;
const JITTER_RATIO = 0.2;
type ActiveRecord = {
runId: string;
nodeId: string;
scenarioSlug: string;
storePurchased?: boolean;
lastPurchase?: PurchaseOfferResponse;
};
function effectKey(runId: string, nodeId: string): string {
return `${runId}:${nodeId}`;
}
function isTransientHttpError(err: unknown): boolean {
if (err instanceof RudderNetworkError) return true;
if (err instanceof RudderHttpError && err.status >= 500) return true;
return false;
}
function isDroppedRunError(err: unknown): boolean {
if (!(err instanceof RudderHttpError)) return false;
return (
err.code === RudderErrorCodes.unknownRun ||
err.code === RudderErrorCodes.runExpired ||
err.code === RudderErrorCodes.runNotActive
);
}
function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function asObjectives(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === 'object' && !Array.isArray(item),
);
}
function asLevel(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
export class ScenarioService {
private readonly active = new Map<string, ActiveRecord>();
private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly droppedRuns = new Set<string>();
private readonly completedRuns = new Set<string>();
private heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
private running = false;
private polling = false;
private pollQueued = false;
constructor(
private readonly ctx: RudderContext,
private readonly domains: ScenarioDomains,
private readonly battlePass: BattlePassService,
private readonly intervalMs: number = DEFAULT_PENDING_INTERVAL_MS,
) {}
get isRunning(): boolean {
return this.active.size > 0;
}
async send(eventName: string): Promise<TriggerScenarioResponse> {
const response = await api.triggerScenario(this.ctx, { event: eventName });
this.domains.player.invalidate();
this.domains.inventory.invalidate();
this.ingest(response.effects ?? []);
return response;
}
async start(): Promise<void> {
if (this.running) return;
this.running = true;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
await this.pollPending();
this.scheduleHeartbeat();
}
clear(): void {
this.running = false;
this.pollQueued = false;
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
for (const timer of this.waitTimers.values()) {
clearTimeout(timer);
}
this.waitTimers.clear();
this.active.clear();
this.droppedRuns.clear();
this.completedRuns.clear();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
}
private readonly onVisibilityChange = (): void => {
if (this.running && typeof document !== 'undefined' && !document.hidden) {
void this.heartbeatTick();
}
};
private scheduleHeartbeat(): void {
if (!this.running) return;
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
this.heartbeatTimer = setTimeout(() => {
void this.heartbeatTick();
}, this.intervalMs * jitter);
}
private async heartbeatTick(): Promise<void> {
if (!this.running) return;
if (typeof document !== 'undefined' && document.hidden) {
this.scheduleHeartbeat();
return;
}
await this.pollPending();
this.scheduleHeartbeat();
}
private async pollPending(): Promise<void> {
if (this.polling) {
this.pollQueued = true;
return;
}
this.polling = true;
try {
do {
this.pollQueued = false;
try {
const response = await api.listPendingScenarioEffects(this.ctx);
this.reconcile(response.effects ?? []);
} catch {
}
} while (this.pollQueued);
} finally {
this.polling = false;
}
}
private reconcile(effects: PendingEffect[]): void {
const incoming = effects.filter((effect) => !this.droppedRuns.has(effect.runId));
const incomingKeys = new Set(
incoming.map((effect) => effectKey(effect.runId, effect.nodeId)),
);
const before = new Set(this.active.keys());
this.ingest(incoming);
for (const [key, record] of [...this.active.entries()]) {
if (incomingKeys.has(key)) continue;
this.deactivate(key);
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
const after = new Set(this.active.keys());
if (before.size !== after.size || [...before].some((key) => !after.has(key))) {
this.domains.player.invalidate();
this.domains.inventory.invalidate();
}
}
private ingest(effects: PendingEffect[]): void {
for (const effect of effects) {
this.ingestOne(effect);
}
}
private ingestOne(pending: PendingEffect): void {
if (this.droppedRuns.has(pending.runId)) return;
const key = effectKey(pending.runId, pending.nodeId);
if (this.active.has(key)) {
if (pending.waitDeadline && !this.waitTimers.has(key)) {
this.scheduleWait(key, pending.waitDeadline);
}
return;
}
const record: ActiveRecord = {
runId: pending.runId,
nodeId: pending.nodeId,
scenarioSlug: pending.scenarioSlug,
};
this.active.set(key, record);
this.completedRuns.delete(pending.runId);
if (pending.waitDeadline) {
this.scheduleWait(key, pending.waitDeadline);
}
this.dispatch(pending, record);
}
private dispatch(pending: PendingEffect, record: ActiveRecord): void {
switch (pending.type) {
case 'notification':
this.ctx.effects.emitNotification({
title: asString(pending.data.title),
message: asString(pending.data.message),
done: () => this.complete(record, 'output'),
});
break;
case 'store':
this.dispatchStore(pending, record);
break;
case 'wait':
this.ctx.effects.emitWait({
deadlineUtc: pending.waitDeadline
? new Date(pending.waitDeadline)
: new Date(),
});
break;
case 'leaderboard': {
const end = () => this.complete(record, 'onEnd');
const claim = () => this.complete(record, 'onClaim');
this.ctx.effects.emitLeaderboard({
end,
claim,
rewardClaimed: claim,
});
break;
}
case 'quest':
this.ctx.effects.emitQuest({
name: asString(pending.data.name),
objectives: asObjectives(pending.data.objectives),
reportProgress: (objectiveId, amount) =>
this.reportQuestProgress(record, objectiveId, amount),
});
break;
case 'battlepass':
this.dispatchBattlePass(record);
break;
case 'battlepass_level':
this.ctx.effects.emitBattlePassLevel({
level: asLevel(pending.data.levelNumber),
claim: () => this.complete(record, 'onComplete'),
});
break;
default:
console.warn(
`[Rudder] Unsupported scenario node type '${pending.type}' (${pending.nodeId})`,
);
this.dropRun(
record.runId,
record.nodeId,
new Error(`Unsupported scenario node type '${pending.type}'`),
);
break;
}
}
private dispatchStore(pending: PendingEffect, record: ActiveRecord): void {
const slug = asString(pending.data.storeSlug);
this.ctx.effects.emitStoreOffer(
this.domains.stores.getBySlug(slug).then((store) => ({
store,
offers: store.offers,
message: typeof pending.data.message === 'string' ? pending.data.message : undefined,
buy: (offer, options) => this.buyStore(record, offer, options),
dismiss: () => this.dismissStore(record),
})),
);
}
private dispatchBattlePass(record: ActiveRecord): void {
const { scenarioSlug, nodeId, runId } = record;
this.ctx.effects.emitBattlePass({
getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
addXp: (source, amount) =>
this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
claimReward: (level, track) =>
this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
purchasePremium: async () => {
const response = await this.battlePass.purchasePremium({
scenarioSlug,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
if (response.success) {
await this.complete(record, 'onPremiumPurchase');
}
return response;
},
levelUp: () => this.complete(record, 'onLevelUp'),
end: () => this.complete(record, 'onComplete'),
});
}
private async buyStore(
record: ActiveRecord,
offer: OfferHandle,
options?: BuyOptions,
): Promise<PurchaseOfferResponse> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) {
return { success: false, error: 'store session already resolved' };
}
if (!record.storePurchased) {
const purchase = await offer.buy(options);
if (!purchase.success) return purchase;
record.storePurchased = true;
record.lastPurchase = purchase;
}
await this.complete(record, 'onPurchase');
return record.lastPurchase ?? { success: true };
}
private async dismissStore(record: ActiveRecord): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (record.storePurchased) return;
await this.complete(record, 'onDecline');
}
private async reportQuestProgress(
record: ActiveRecord,
objectiveId: string,
amount = 1,
): Promise<void> {
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
counterKey: objectiveId,
amount,
runId: record.runId,
});
if (!response?.completed) return;
this.domains.player.invalidate();
this.domains.inventory.invalidate();
const key = effectKey(record.runId, record.nodeId);
if (this.active.has(key)) {
this.deactivate(key);
}
if (response.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch {
}
}
private async complete(record: ActiveRecord, handle: string): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (this.droppedRuns.has(record.runId)) return;
try {
const response = await api.handleScenarioCallback(this.ctx, {
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
handle,
runId: record.runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (!this.active.has(key)) return;
this.deactivate(key);
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch (err) {
if (isTransientHttpError(err)) return;
if (isDroppedRunError(err)) {
const error = err instanceof Error ? err : new Error(String(err));
this.dropRun(record.runId, record.nodeId, error);
throw err;
}
throw err;
}
}
private scheduleWait(key: string, waitDeadline: string): void {
const deadline = Date.parse(waitDeadline);
if (Number.isNaN(deadline)) return;
const remaining = Math.max(0, deadline - Date.now());
const existing = this.waitTimers.get(key);
if (existing) clearTimeout(existing);
this.waitTimers.set(
key,
setTimeout(() => {
this.waitTimers.delete(key);
void this.pollPending();
}, remaining),
);
}
private deactivate(key: string): void {
const timer = this.waitTimers.get(key);
if (timer) {
clearTimeout(timer);
this.waitTimers.delete(key);
}
this.active.delete(key);
}
private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId);
let scenarioSlug = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
this.deactivate(key);
}
console.warn(
`[Rudder] Scenario run ${runId} failed at node ${nodeId}: ${error.message}`,
);
this.ctx.effects.emitScenarioFailed({
runId,
scenarioSlug,
nodeId,
error,
});
}
private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
if (this.droppedRuns.has(runId)) return;
if (this.completedRuns.has(runId)) return;
for (const record of this.active.values()) {
if (record.runId === runId) return;
}
this.completedRuns.add(runId);
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
}
}
-54
View File
@@ -1,54 +0,0 @@
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);
}
}
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;
}
}
-29
View File
@@ -1,29 +0,0 @@
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 ?? [],
};
});
}
-77
View File
@@ -1,77 +0,0 @@
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,
offerSlug: string,
options?: BuyOptions,
) => Promise<PurchaseOfferResponse>;
export class OfferHandle {
public readonly id: string;
public readonly slug: 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');
}
if (!offer.slug) {
throw new Error('Rudder Stores: offer.slug is required');
}
this.id = offer.id;
this.slug = offer.slug;
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.slug, 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),
);
}
}
+7 -1
View File
@@ -19,6 +19,8 @@ export interface RequestContext {
baseUrl: string;
tokenStore: TokenStore;
requestTimeoutMs?: number;
/** Sent with `platform: 'web'` on token refresh; omitted when not set. */
appVersion?: string;
/** 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. */
@@ -168,7 +170,11 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken } satisfies RefreshAccessTokenRequest),
body: JSON.stringify({
refreshToken,
platform: 'web',
appVersion: ctx.appVersion,
} satisfies RefreshAccessTokenRequest),
signal: controller.signal,
});
if (!response.ok) return false;