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