chore: drop comments that restate the code
This commit is contained in:
@@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* AuthService — player authentication.
|
|
||||||
*
|
|
||||||
* Handles login via device ID (the primary auth flow for game clients), Google,
|
|
||||||
* Apple, or a custom backend webhook, identity linking/unlinking, logout
|
|
||||||
* (token clearing), and auth state observation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { RudderContext } from '../core/context.js';
|
import type { RudderContext } from '../core/context.js';
|
||||||
import { getOrCreateDeviceId } from '../device/DeviceId.js';
|
import { getOrCreateDeviceId } from '../device/DeviceId.js';
|
||||||
import { api } from '../generated/api.js';
|
import { api } from '../generated/api.js';
|
||||||
@@ -21,53 +13,35 @@ export type AuthState = 'signed-in' | 'signed-out';
|
|||||||
export type AuthStateListener = (state: AuthState) => void;
|
export type AuthStateListener = (state: AuthState) => void;
|
||||||
|
|
||||||
export interface LoginWithDeviceOptions {
|
export interface LoginWithDeviceOptions {
|
||||||
/** Player's region code (default: "global"). */
|
|
||||||
region?: string;
|
region?: string;
|
||||||
/** Player's language code (default: "en"). */
|
|
||||||
language?: string;
|
language?: string;
|
||||||
/** Optional player nickname; omitted from the request when not set. */
|
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginWithCustomOptions {
|
export interface LoginWithCustomOptions {
|
||||||
/** Arbitrary payload forwarded to the developer's custom auth webhook. */
|
|
||||||
customData: Record<string, unknown>;
|
customData: Record<string, unknown>;
|
||||||
/** Name of the custom auth provider; omitted means the default provider. */
|
|
||||||
provider?: string;
|
provider?: string;
|
||||||
/** Player's region code (default: "global"). */
|
|
||||||
region?: string;
|
region?: string;
|
||||||
/** Player's language code (default: "en"). */
|
|
||||||
language?: string;
|
language?: string;
|
||||||
/** Optional player nickname; omitted from the request when not set. */
|
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginWithGoogleOptions {
|
export interface LoginWithGoogleOptions {
|
||||||
/** Google ID token obtained from the Google sign-in flow. */
|
|
||||||
idToken: string;
|
idToken: string;
|
||||||
/** Player's region code (default: "global"). */
|
|
||||||
region?: string;
|
region?: string;
|
||||||
/** Player's language code (default: "en"). */
|
|
||||||
language?: string;
|
language?: string;
|
||||||
/** Optional player nickname; omitted from the request when not set. */
|
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginWithAppleOptions {
|
export interface LoginWithAppleOptions {
|
||||||
/** Apple ID token (identity token) obtained from Sign in with Apple. */
|
|
||||||
idToken: string;
|
idToken: string;
|
||||||
/** Player's region code (default: "global"). */
|
|
||||||
region?: string;
|
region?: string;
|
||||||
/** Player's language code (default: "en"). */
|
|
||||||
language?: string;
|
language?: string;
|
||||||
/** Optional player nickname; omitted from the request when not set. */
|
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LinkWithCustomOptions {
|
export interface LinkWithCustomOptions {
|
||||||
/** Arbitrary payload forwarded to the developer's custom auth webhook. */
|
|
||||||
customData: Record<string, unknown>;
|
customData: Record<string, unknown>;
|
||||||
/** Name of the custom auth provider; omitted means the default provider. */
|
|
||||||
provider?: string;
|
provider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,19 +57,12 @@ export class AuthService {
|
|||||||
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
|
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True while an access token is present in the token store. */
|
|
||||||
get isAuthenticated(): boolean {
|
get isAuthenticated(): boolean {
|
||||||
return this.ctx.options.tokenStore.getAccessToken() !== null;
|
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 {
|
onAuthStateChange(listener: AuthStateListener): () => void {
|
||||||
this.listeners.add(listener);
|
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';
|
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
|
||||||
listener(this.state);
|
listener(this.state);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -103,12 +70,6 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
async loginWithDevice(options: LoginWithDeviceOptions = {}): Promise<LoginViaDeviceResponse> {
|
||||||
const { region = 'global', language = 'en', nickname } = options;
|
const { region = 'global', language = 'en', nickname } = options;
|
||||||
const response = await api.loginViaDevice(this.ctx, {
|
const response = await api.loginViaDevice(this.ctx, {
|
||||||
@@ -130,13 +91,6 @@ export class AuthService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticates via the project's custom authorization webhook and returns
|
|
||||||
* access + refresh tokens.
|
|
||||||
*
|
|
||||||
* The payload is forwarded to the developer's backend; on success, tokens are
|
|
||||||
* saved to the client's TokenStore.
|
|
||||||
*/
|
|
||||||
async loginWithCustom(options: LoginWithCustomOptions): Promise<LoginViaCustomResponse> {
|
async loginWithCustom(options: LoginWithCustomOptions): Promise<LoginViaCustomResponse> {
|
||||||
const { customData, provider, region = 'global', language = 'en', nickname } = options;
|
const { customData, provider, region = 'global', language = 'en', nickname } = options;
|
||||||
const response = await api.loginViaCustom(this.ctx, {
|
const response = await api.loginViaCustom(this.ctx, {
|
||||||
@@ -159,11 +113,6 @@ export class AuthService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticates with a Google ID token and returns access + refresh tokens.
|
|
||||||
*
|
|
||||||
* On success, tokens are saved to the client's TokenStore.
|
|
||||||
*/
|
|
||||||
async loginWithGoogle(options: LoginWithGoogleOptions): Promise<LoginViaGoogleResponse> {
|
async loginWithGoogle(options: LoginWithGoogleOptions): Promise<LoginViaGoogleResponse> {
|
||||||
const { idToken, region = 'global', language = 'en', nickname } = options;
|
const { idToken, region = 'global', language = 'en', nickname } = options;
|
||||||
const response = await api.loginViaGoogle(this.ctx, {
|
const response = await api.loginViaGoogle(this.ctx, {
|
||||||
@@ -185,11 +134,6 @@ export class AuthService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticates with an Apple ID token and returns access + refresh tokens.
|
|
||||||
*
|
|
||||||
* On success, tokens are saved to the client's TokenStore.
|
|
||||||
*/
|
|
||||||
async loginWithApple(options: LoginWithAppleOptions): Promise<LoginViaAppleResponse> {
|
async loginWithApple(options: LoginWithAppleOptions): Promise<LoginViaAppleResponse> {
|
||||||
const { idToken, region = 'global', language = 'en', nickname } = options;
|
const { idToken, region = 'global', language = 'en', nickname } = options;
|
||||||
const response = await api.loginViaApple(this.ctx, {
|
const response = await api.loginViaApple(this.ctx, {
|
||||||
@@ -211,26 +155,14 @@ export class AuthService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Links a Google identity to the currently signed-in player. Requires an
|
|
||||||
* active session (the access token is sent automatically).
|
|
||||||
*/
|
|
||||||
async linkWithGoogle(idToken: string): Promise<void> {
|
async linkWithGoogle(idToken: string): Promise<void> {
|
||||||
await api.linkViaGoogle(this.ctx, { idToken });
|
await api.linkViaGoogle(this.ctx, { idToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Links an Apple identity to the currently signed-in player. Requires an
|
|
||||||
* active session (the access token is sent automatically).
|
|
||||||
*/
|
|
||||||
async linkWithApple(idToken: string): Promise<void> {
|
async linkWithApple(idToken: string): Promise<void> {
|
||||||
await api.linkViaApple(this.ctx, { idToken });
|
await api.linkViaApple(this.ctx, { idToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Links a custom identity to the currently signed-in player via the project's
|
|
||||||
* custom authorization webhook. Requires an active session.
|
|
||||||
*/
|
|
||||||
async linkWithCustom(options: LinkWithCustomOptions): Promise<void> {
|
async linkWithCustom(options: LinkWithCustomOptions): Promise<void> {
|
||||||
await api.linkViaCustom(this.ctx, {
|
await api.linkViaCustom(this.ctx, {
|
||||||
customData: options.customData,
|
customData: options.customData,
|
||||||
@@ -238,27 +170,16 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlinks an identity from the currently signed-in player. The provider is
|
|
||||||
* the full provider string: `device`, `google`, `apple`, `custom`, or
|
|
||||||
* `custom:<name>`.
|
|
||||||
*/
|
|
||||||
async unlinkIdentity(provider: string): Promise<void> {
|
async unlinkIdentity(provider: string): Promise<void> {
|
||||||
await api.unlinkIdentity(this.ctx, { provider });
|
await api.unlinkIdentity(this.ctx, { provider });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears all stored tokens (logout). */
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.ctx.options.tokenStore.clear();
|
this.ctx.options.tokenStore.clear();
|
||||||
this.stopRuntime();
|
this.stopRuntime();
|
||||||
this.setState('signed-out');
|
this.setState('signed-out');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by the transport when the session is unrecoverable (refresh failed).
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
notifySessionExpired(): void {
|
notifySessionExpired(): void {
|
||||||
this.setState('signed-out');
|
this.setState('signed-out');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,3 @@
|
|||||||
/**
|
|
||||||
* RudderClient — the main entry point of `@rudder/sdk`; create it with
|
|
||||||
* {@link createClient}.
|
|
||||||
*
|
|
||||||
* Composes the kernel domains (auth, player, catalog, storage, modules) and the HTTP transport every service uses to reach the API.
|
|
||||||
*
|
|
||||||
* The client builds a {@link RudderContext} first and injects it into every
|
|
||||||
* domain, so they depend on that narrow surface rather than on the client
|
|
||||||
* (which keeps the module graph acyclic).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ResolvedRudderClientOptions,
|
ResolvedRudderClientOptions,
|
||||||
RudderClientOptions,
|
RudderClientOptions,
|
||||||
@@ -102,7 +91,6 @@ export class RudderClient {
|
|||||||
this.runtimeStarted = false;
|
this.runtimeStarted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
async startRuntimeAfterLogin(): Promise<void> {
|
async startRuntimeAfterLogin(): Promise<void> {
|
||||||
if (this.runtimeStarted) {
|
if (this.runtimeStarted) {
|
||||||
this.invalidateAll();
|
this.invalidateAll();
|
||||||
@@ -112,7 +100,6 @@ export class RudderClient {
|
|||||||
this.syncEngine.start();
|
this.syncEngine.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
stopRuntimeAfterLogout(): void {
|
stopRuntimeAfterLogout(): void {
|
||||||
this.runtimeStarted = false;
|
this.runtimeStarted = false;
|
||||||
this.syncEngine.stop();
|
this.syncEngine.stop();
|
||||||
@@ -132,7 +119,6 @@ export class RudderClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a client. Only `projectKey` is required. */
|
|
||||||
export function createClient(options: RudderClientOptions): RudderClient {
|
export function createClient(options: RudderClientOptions): RudderClient {
|
||||||
return new RudderClient(options);
|
return new RudderClient(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import type { TokenStore } from '../token/TokenStore.js';
|
import type { TokenStore } from '../token/TokenStore.js';
|
||||||
|
|
||||||
export interface RudderClientOptions {
|
export interface RudderClientOptions {
|
||||||
/** Project SDK key; it also selects the environment (staging or prod). */
|
|
||||||
projectKey: string;
|
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;
|
tokenStore?: TokenStore;
|
||||||
|
|
||||||
/**
|
|
||||||
* Game build version sent as `appVersion` on every login and token refresh
|
|
||||||
* (used by segments). Omitted when not set.
|
|
||||||
*/
|
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
|
|
||||||
/** Per-request timeout in milliseconds (default: 10000). */
|
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
|
|
||||||
/** API base URL (default: "https://api.rudder.build"). */
|
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
||||||
/** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */
|
|
||||||
syncIntervalMs?: number;
|
syncIntervalMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Client options with all defaults resolved — what `client.options` exposes. */
|
|
||||||
export interface ResolvedRudderClientOptions extends RudderClientOptions {
|
export interface ResolvedRudderClientOptions extends RudderClientOptions {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
tokenStore: TokenStore;
|
tokenStore: TokenStore;
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
/**
|
|
||||||
* 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 code (open-ended: platform
|
|
||||||
* codes and module business error codes) or an SDK-local code
|
|
||||||
* (e.g. `sdk/invalid-options` for client configuration errors).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Code carried by SDK errors: a server code or an SDK-local code. */
|
|
||||||
export type RudderErrorCodeLike = string;
|
export type RudderErrorCodeLike = string;
|
||||||
|
|
||||||
/** Code for client-side configuration validation failures. */
|
|
||||||
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
|
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
|
||||||
|
|
||||||
/** Base error class for all SDK errors. */
|
|
||||||
export class RudderError extends Error {
|
export class RudderError extends Error {
|
||||||
public readonly code?: RudderErrorCodeLike;
|
public readonly code?: RudderErrorCodeLike;
|
||||||
|
|
||||||
@@ -25,7 +12,6 @@ export class RudderError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thrown when a network request fails (fetch throws, timeout, DNS, etc.). */
|
|
||||||
export class RudderNetworkError extends RudderError {
|
export class RudderNetworkError extends RudderError {
|
||||||
constructor(cause: unknown) {
|
constructor(cause: unknown) {
|
||||||
super(`Network error: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
super(`Network error: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
||||||
@@ -33,7 +19,6 @@ export class RudderNetworkError extends RudderError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thrown when the server returns a non-2xx HTTP status (except 401). */
|
|
||||||
export class RudderHttpError extends RudderError {
|
export class RudderHttpError extends RudderError {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
@@ -46,7 +31,6 @@ export class RudderHttpError extends RudderError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thrown when the server returns HTTP 401 Unauthorized. */
|
|
||||||
export class RudderAuthError extends RudderHttpError {
|
export class RudderAuthError extends RudderHttpError {
|
||||||
constructor(statusText: string, body: string, code?: RudderErrorCodeLike) {
|
constructor(statusText: string, body: string, code?: RudderErrorCodeLike) {
|
||||||
super(401, statusText, body, code);
|
super(401, statusText, body, code);
|
||||||
@@ -54,10 +38,6 @@ export class RudderAuthError extends RudderHttpError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by `modules.call` when a module function fails with HTTP 422
|
|
||||||
* `{error, code, requestId}`. `C` narrows `code` to a generated error union.
|
|
||||||
*/
|
|
||||||
export class RudderModuleError<C extends string = string> extends RudderHttpError {
|
export class RudderModuleError<C extends string = string> extends RudderHttpError {
|
||||||
declare public readonly code: C;
|
declare public readonly code: C;
|
||||||
public readonly requestId?: string;
|
public readonly requestId?: string;
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js';
|
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js';
|
||||||
import type { RequestOptions } from '../transport/request.js';
|
import type { RequestOptions } from '../transport/request.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* RudderContext — the low-level surface every feature service depends on:
|
|
||||||
* the HTTP transport and client options.
|
|
||||||
*
|
|
||||||
* 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 {
|
export interface RudderContext {
|
||||||
readonly options: ResolvedRudderClientOptions;
|
readonly options: ResolvedRudderClientOptions;
|
||||||
request<TResponse>(
|
request<TResponse>(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/** UUID v4. Falls back to getRandomValues where randomUUID is missing (non-secure contexts, old runtimes). */
|
|
||||||
export function uuidv4(): string {
|
export function uuidv4(): string {
|
||||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||||
const b = crypto.getRandomValues(new Uint8Array(16));
|
const b = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
/**
|
|
||||||
* Device ID provider — generates and persists a unique device identifier.
|
|
||||||
*
|
|
||||||
* Generates a UUID v4 and stores the generated UUID in localStorage under `rudder_device_id`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { uuidv4 } from '../core/uuid.js';
|
import { uuidv4 } from '../core/uuid.js';
|
||||||
|
|
||||||
const STORAGE_KEY = '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 {
|
export function getOrCreateDeviceId(): string {
|
||||||
try {
|
try {
|
||||||
const existing = localStorage.getItem(STORAGE_KEY);
|
const existing = localStorage.getItem(STORAGE_KEY);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage unavailable — generate ephemeral ID.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, id);
|
localStorage.setItem(STORAGE_KEY, id);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage unavailable — ID will be ephemeral for this session.
|
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ import type { RudderContext } from '../core/context.js';
|
|||||||
import { api } from '../generated/api.js';
|
import { api } from '../generated/api.js';
|
||||||
import type { CatalogCurrency } from '../generated/catalog.js';
|
import type { CatalogCurrency } from '../generated/catalog.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* CatalogDomain — currency definitions, keyed by slug. Synced under revision
|
|
||||||
* key `catalog`.
|
|
||||||
*/
|
|
||||||
export class CatalogDomain extends SyncedState<Map<string, CatalogCurrency>> {
|
export class CatalogDomain extends SyncedState<Map<string, CatalogCurrency>> {
|
||||||
readonly syncKey = 'catalog';
|
readonly syncKey = 'catalog';
|
||||||
|
|
||||||
|
|||||||
@@ -8,22 +8,12 @@ import type {
|
|||||||
} from '../generated/modules.js';
|
} from '../generated/modules.js';
|
||||||
|
|
||||||
export interface ModuleCallOptions {
|
export interface ModuleCallOptions {
|
||||||
/** Version the caller was built against; sent as `X-Rudder-Module-Version: <module>@<version>`. */
|
|
||||||
moduleVersion?: string;
|
moduleVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ModulesDomain — calls module functions. Usually reached through a client
|
|
||||||
* generated by `rudder client generate`, not directly.
|
|
||||||
*/
|
|
||||||
export class ModulesDomain {
|
export class ModulesDomain {
|
||||||
constructor(private readonly ctx: RudderContext) {}
|
constructor(private readonly ctx: RudderContext) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls `module.fn` and returns its `result`. Each call gets one
|
|
||||||
* Idempotency-Key reused across retries (network errors, 5xx, 409 `conflict`).
|
|
||||||
* Rejects with {@link RudderModuleError} on 422.
|
|
||||||
*/
|
|
||||||
async call<T = unknown>(
|
async call<T = unknown>(
|
||||||
module: string,
|
module: string,
|
||||||
fn: string,
|
fn: string,
|
||||||
@@ -49,7 +39,6 @@ export class ModulesDomain {
|
|||||||
try {
|
try {
|
||||||
payload = JSON.parse(err.body);
|
payload = JSON.parse(err.body);
|
||||||
} catch {
|
} catch {
|
||||||
// err.code came from this body, so it is JSON; keep the fallback anyway.
|
|
||||||
}
|
}
|
||||||
throw new RudderModuleError(err.statusText, err.body, { ...payload, code: err.code });
|
throw new RudderModuleError(err.statusText, err.body, { ...payload, code: err.code });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import type { RudderContext } from '../core/context.js';
|
|||||||
import { api } from '../generated/api.js';
|
import { api } from '../generated/api.js';
|
||||||
import type { PlayerProfile } from '../generated/player.js';
|
import type { PlayerProfile } from '../generated/player.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* PlayerDomain — the observable player profile (player, identities,
|
|
||||||
* balances, items).
|
|
||||||
* Synced under revision key `profile`.
|
|
||||||
*/
|
|
||||||
export class PlayerDomain extends SyncedState<PlayerProfile> {
|
export class PlayerDomain extends SyncedState<PlayerProfile> {
|
||||||
readonly syncKey = 'profile';
|
readonly syncKey = 'profile';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// @rudder/sdk — Rudder client SDK for web games
|
|
||||||
|
|
||||||
// Client
|
|
||||||
export { RudderClient, createClient } from './client/RudderClient.js';
|
export { RudderClient, createClient } from './client/RudderClient.js';
|
||||||
export type { RudderClientOptions } from './client/RudderClientOptions.js';
|
export type { RudderClientOptions } from './client/RudderClientOptions.js';
|
||||||
export {
|
export {
|
||||||
@@ -13,11 +10,9 @@ export {
|
|||||||
} from './client/RudderError.js';
|
} from './client/RudderError.js';
|
||||||
export type { RudderErrorCodeLike } from './client/RudderError.js';
|
export type { RudderErrorCodeLike } from './client/RudderError.js';
|
||||||
|
|
||||||
// Token management
|
|
||||||
export type { TokenStore } from './token/TokenStore.js';
|
export type { TokenStore } from './token/TokenStore.js';
|
||||||
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
|
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
|
||||||
|
|
||||||
// Services — types only; instances live on the client (client.auth).
|
|
||||||
export type {
|
export type {
|
||||||
AuthService,
|
AuthService,
|
||||||
AuthState,
|
AuthState,
|
||||||
@@ -29,7 +24,6 @@ export type {
|
|||||||
LoginWithGoogleOptions,
|
LoginWithGoogleOptions,
|
||||||
} from './auth/AuthService.js';
|
} from './auth/AuthService.js';
|
||||||
|
|
||||||
// Observable state primitives
|
|
||||||
export { SyncedState } from './state/SyncedState.js';
|
export { SyncedState } from './state/SyncedState.js';
|
||||||
export type {
|
export type {
|
||||||
SyncedSnapshot,
|
SyncedSnapshot,
|
||||||
@@ -37,15 +31,11 @@ export type {
|
|||||||
SyncedListener,
|
SyncedListener,
|
||||||
} from './state/SyncedState.js';
|
} from './state/SyncedState.js';
|
||||||
|
|
||||||
// Domains — types only; instances live on the client
|
|
||||||
// (client.player, client.catalog, client.storage, client.modules).
|
|
||||||
export type { PlayerDomain } from './domains/PlayerDomain.js';
|
export type { PlayerDomain } from './domains/PlayerDomain.js';
|
||||||
export type { CatalogDomain } from './domains/CatalogDomain.js';
|
export type { CatalogDomain } from './domains/CatalogDomain.js';
|
||||||
export type { ModulesDomain, ModuleCallOptions } from './domains/ModulesDomain.js';
|
export type { ModulesDomain, ModuleCallOptions } from './domains/ModulesDomain.js';
|
||||||
export type { StorageDomain } from './domains/StorageDomain.js';
|
export type { StorageDomain } from './domains/StorageDomain.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 { CurrencyBalance, InventoryStack, Player, PlayerProfile, PlayerProfileIdentity } from './generated/player.js';
|
export type { CurrencyBalance, InventoryStack, Player, PlayerProfile, PlayerProfileIdentity } from './generated/player.js';
|
||||||
export type { CatalogCurrency } from './generated/catalog.js';
|
export type { CatalogCurrency } from './generated/catalog.js';
|
||||||
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
|
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
|
||||||
|
|||||||
@@ -6,21 +6,11 @@ export const DEFAULT_SYNC_INTERVAL_MS = 30_000;
|
|||||||
const REVISIONS_STORAGE_KEY = 'rudder_revisions';
|
const REVISIONS_STORAGE_KEY = 'rudder_revisions';
|
||||||
const JITTER_RATIO = 0.2;
|
const JITTER_RATIO = 0.2;
|
||||||
|
|
||||||
/** A domain the sync engine can refresh when its server revision grows. */
|
|
||||||
export interface SyncableDomain {
|
export interface SyncableDomain {
|
||||||
readonly syncKey: string;
|
readonly syncKey: string;
|
||||||
invalidate(): void;
|
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 {
|
export class SyncEngine {
|
||||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
private revisions: Record<string, number> = {};
|
private revisions: Record<string, number> = {};
|
||||||
@@ -89,7 +79,6 @@ export class SyncEngine {
|
|||||||
const revisions = await api.getRevisions(this.ctx);
|
const revisions = await api.getRevisions(this.ctx);
|
||||||
this.applyRevisions(revisions ?? {});
|
this.applyRevisions(revisions ?? {});
|
||||||
} catch {
|
} catch {
|
||||||
// Network/server errors must not stop the loop.
|
|
||||||
} finally {
|
} finally {
|
||||||
this.polling = false;
|
this.polling = false;
|
||||||
}
|
}
|
||||||
@@ -97,9 +86,6 @@ export class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyRevisions(next: Record<string, number>): void {
|
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;
|
const skipDiff = !this.baselineEstablished && Object.keys(this.revisions).length === 0;
|
||||||
this.baselineEstablished = true;
|
this.baselineEstablished = true;
|
||||||
if (!skipDiff) {
|
if (!skipDiff) {
|
||||||
@@ -115,7 +101,6 @@ export class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private invalidate(key: string): void {
|
private invalidate(key: string): void {
|
||||||
// Unknown keys have no registered domain and are ignored.
|
|
||||||
this.byKey.get(key)?.invalidate();
|
this.byKey.get(key)?.invalidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +122,6 @@ function persistRevisions(revisions: Record<string, number>): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(REVISIONS_STORAGE_KEY, JSON.stringify(revisions));
|
localStorage.setItem(REVISIONS_STORAGE_KEY, JSON.stringify(revisions));
|
||||||
} catch {
|
} catch {
|
||||||
// Storage unavailable — best effort.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +129,5 @@ function clearPersistedRevisions(): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem(REVISIONS_STORAGE_KEY);
|
localStorage.removeItem(REVISIONS_STORAGE_KEY);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage unavailable — silently ignore.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
/**
|
|
||||||
* SyncedState — an observable cell for a single server-synced entity.
|
|
||||||
*
|
|
||||||
* Tracks `{ status, value, 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 type SyncedStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
export interface SyncedSnapshot<T> {
|
export interface SyncedSnapshot<T> {
|
||||||
@@ -26,7 +17,6 @@ export class SyncedState<T> {
|
|||||||
|
|
||||||
constructor(private readonly loader: () => Promise<T>) {}
|
constructor(private readonly loader: () => Promise<T>) {}
|
||||||
|
|
||||||
/** Current data, or undefined when not loaded yet. */
|
|
||||||
get value(): T | undefined {
|
get value(): T | undefined {
|
||||||
return this.snapshot.value;
|
return this.snapshot.value;
|
||||||
}
|
}
|
||||||
@@ -39,10 +29,6 @@ export class SyncedState<T> {
|
|||||||
return this.snapshot.error;
|
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 {
|
onChange(callback: SyncedListener<T>): () => void {
|
||||||
this.listeners.add(callback);
|
this.listeners.add(callback);
|
||||||
callback(this.snapshot);
|
callback(this.snapshot);
|
||||||
@@ -51,7 +37,6 @@ export class SyncedState<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads the entity if needed; deduplicates parallel loads. */
|
|
||||||
load(): Promise<T> {
|
load(): Promise<T> {
|
||||||
if (this.snapshot.status === 'ready') {
|
if (this.snapshot.status === 'ready') {
|
||||||
return Promise.resolve(this.snapshot.value as T);
|
return Promise.resolve(this.snapshot.value as T);
|
||||||
@@ -59,25 +44,16 @@ export class SyncedState<T> {
|
|||||||
return this.startLoad(false);
|
return this.startLoad(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forces a (re)load even when data is already present. */
|
|
||||||
reload(): Promise<T> {
|
reload(): Promise<T> {
|
||||||
return this.startLoad(true);
|
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 {
|
invalidate(): void {
|
||||||
if (this.invalidateScheduled) return;
|
if (this.invalidateScheduled) return;
|
||||||
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
||||||
this.invalidateScheduled = true;
|
this.invalidateScheduled = true;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
// A reset() between scheduling and flushing cancels the reload
|
// A reset() between scheduling and flushing cancels the reload.
|
||||||
// (the entity was dropped on logout).
|
|
||||||
if (!this.invalidateScheduled) return;
|
if (!this.invalidateScheduled) return;
|
||||||
this.invalidateScheduled = false;
|
this.invalidateScheduled = false;
|
||||||
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
||||||
@@ -85,11 +61,6 @@ export class SyncedState<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Drops all state back to idle (logout).
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.generation++;
|
this.generation++;
|
||||||
this.pending = undefined;
|
this.pending = undefined;
|
||||||
|
|||||||
@@ -1,33 +1,16 @@
|
|||||||
/**
|
|
||||||
* 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 {
|
export interface TokenStore {
|
||||||
/** Returns the current access token, or null if not authenticated. */
|
|
||||||
getAccessToken(): string | null;
|
getAccessToken(): string | null;
|
||||||
|
|
||||||
/** Returns the current refresh token, or null if not available. */
|
|
||||||
getRefreshToken(): string | null;
|
getRefreshToken(): string | null;
|
||||||
|
|
||||||
/** Persists access and refresh tokens after a successful login. */
|
|
||||||
saveTokens(accessToken: string, refreshToken: string): void;
|
saveTokens(accessToken: string, refreshToken: string): void;
|
||||||
|
|
||||||
/** Removes all stored tokens (logout). */
|
|
||||||
clear(): void;
|
clear(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY_ACCESS = 'rudder_access_token';
|
const STORAGE_KEY_ACCESS = 'rudder_access_token';
|
||||||
const STORAGE_KEY_REFRESH = 'rudder_refresh_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 {
|
export function createDefaultTokenStore(): TokenStore {
|
||||||
try {
|
try {
|
||||||
const probe = 'rudder_storage_probe';
|
const probe = 'rudder_storage_probe';
|
||||||
@@ -39,11 +22,6 @@ export function createDefaultTokenStore(): TokenStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a TokenStore backed by the browser's localStorage.
|
|
||||||
*
|
|
||||||
* Uses keys `rudder_access_token` and `rudder_refresh_token`.
|
|
||||||
*/
|
|
||||||
export function createLocalStorageTokenStore(): TokenStore {
|
export function createLocalStorageTokenStore(): TokenStore {
|
||||||
return {
|
return {
|
||||||
getAccessToken(): string | null {
|
getAccessToken(): string | null {
|
||||||
@@ -67,7 +45,6 @@ export function createLocalStorageTokenStore(): TokenStore {
|
|||||||
localStorage.setItem(STORAGE_KEY_ACCESS, accessToken);
|
localStorage.setItem(STORAGE_KEY_ACCESS, accessToken);
|
||||||
localStorage.setItem(STORAGE_KEY_REFRESH, refreshToken);
|
localStorage.setItem(STORAGE_KEY_REFRESH, refreshToken);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage full or unavailable — silently ignore.
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -76,7 +53,6 @@ export function createLocalStorageTokenStore(): TokenStore {
|
|||||||
localStorage.removeItem(STORAGE_KEY_ACCESS);
|
localStorage.removeItem(STORAGE_KEY_ACCESS);
|
||||||
localStorage.removeItem(STORAGE_KEY_REFRESH);
|
localStorage.removeItem(STORAGE_KEY_REFRESH);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage unavailable — silently ignore.
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-44
@@ -1,8 +1,3 @@
|
|||||||
/**
|
|
||||||
* Core HTTP transport — fetch-based with auth injection, timeouts, retries,
|
|
||||||
* single-flight token refresh, and typed error mapping.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RudderNetworkError, RudderHttpError, RudderAuthError } from '../client/RudderError.js';
|
import { RudderNetworkError, RudderHttpError, RudderAuthError } from '../client/RudderError.js';
|
||||||
import type { TokenStore } from '../token/TokenStore.js';
|
import type { TokenStore } from '../token/TokenStore.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -15,14 +10,8 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const RETRY_BASE_DELAY_MS = 300;
|
const RETRY_BASE_DELAY_MS = 300;
|
||||||
|
|
||||||
/** Per-call transport options. */
|
|
||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
/** Extra headers, sent unchanged on every attempt (retries and the post-refresh retry). */
|
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
/**
|
|
||||||
* Retry non-GET requests like GETs (network errors, 5xx) and also on 409 `conflict`.
|
|
||||||
* Only for calls made safe by an idempotency key. GETs always retry.
|
|
||||||
*/
|
|
||||||
retry?: boolean;
|
retry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,28 +19,12 @@ export interface RequestContext {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
tokenStore: TokenStore;
|
tokenStore: TokenStore;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
/** Sent with `platform: 'web'` on token refresh; omitted when not set. */
|
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
/** Single-flight refresh state shared by all requests of one client. */
|
/** Single-flight refresh state shared by all requests of one client. */
|
||||||
refreshState?: { pending?: Promise<boolean> };
|
refreshState?: { pending?: Promise<boolean> };
|
||||||
/** Called when the refresh flow fails — lets the client stop its runtime. */
|
|
||||||
onAuthFailure?: () => void;
|
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 up to 2 times (jittered 300ms × 2^n backoff): GETs on network errors and
|
|
||||||
* 5xx; `options.retry` requests also on 409 with code `conflict`
|
|
||||||
* - 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>(
|
export async function request<TResponse>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -76,7 +49,6 @@ export async function request<TResponse>(
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const bodyText = await response.text().catch(() => '');
|
const bodyText = await response.text().catch(() => '');
|
||||||
|
|
||||||
// Try to extract machine-readable code from error body.
|
|
||||||
let code: string | undefined;
|
let code: string | undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(bodyText);
|
const parsed = JSON.parse(bodyText);
|
||||||
@@ -84,7 +56,6 @@ export async function request<TResponse>(
|
|||||||
code = parsed.code;
|
code = parsed.code;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// non-JSON body — ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -93,7 +64,6 @@ export async function request<TResponse>(
|
|||||||
throw new RudderHttpError(response.status, response.statusText, bodyText, code);
|
throw new RudderHttpError(response.status, response.statusText, bodyText, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 204 No Content — no body to parse.
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return undefined as unknown as TResponse;
|
return undefined as unknown as TResponse;
|
||||||
}
|
}
|
||||||
@@ -101,7 +71,6 @@ export async function request<TResponse>(
|
|||||||
return response.json() as Promise<TResponse>;
|
return response.json() as Promise<TResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetches with per-attempt timeout; retries per {@link RequestOptions.retry}. */
|
|
||||||
async function requestWithRetry(
|
async function requestWithRetry(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -168,10 +137,6 @@ async function fetchOnce(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
async function refreshTokens(ctx: RequestContext): Promise<boolean> {
|
||||||
const state = (ctx.refreshState ??= {});
|
const state = (ctx.refreshState ??= {});
|
||||||
state.pending ??= doRefreshTokens(ctx).finally(() => {
|
state.pending ??= doRefreshTokens(ctx).finally(() => {
|
||||||
@@ -188,8 +153,9 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
const timer = setTimeout(() => controller.abort(), timeout);
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -199,19 +165,22 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
|
|||||||
} satisfies RefreshAccessTokenRequest),
|
} satisfies RefreshAccessTokenRequest),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok) return false;
|
} catch (err) {
|
||||||
const tokens = (await response.json()) as RefreshAccessTokenResponse;
|
throw new RudderNetworkError(err);
|
||||||
if (!tokens.accessToken || !tokens.refreshToken) return false;
|
|
||||||
ctx.tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
if (response.status >= 400 && response.status < 500 && response.status !== 429) return false;
|
||||||
|
if (!response.ok) {
|
||||||
|
const bodyText = await response.text().catch(() => '');
|
||||||
|
throw new RudderHttpError(response.status, response.statusText, bodyText);
|
||||||
|
}
|
||||||
|
const tokens = (await response.json()) as RefreshAccessTokenResponse;
|
||||||
|
if (!tokens.accessToken || !tokens.refreshToken) return false;
|
||||||
|
ctx.tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 300ms × 2^attempt, scaled by a random factor in [0.5, 1.5). */
|
|
||||||
function backoff(attempt: number): Promise<void> {
|
function backoff(attempt: number): Promise<void> {
|
||||||
const ms = RETRY_BASE_DELAY_MS * 2 ** attempt * (0.5 + Math.random());
|
const ms = RETRY_BASE_DELAY_MS * 2 ** attempt * (0.5 + Math.random());
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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('&')}`;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
// Minimal fetch wrapper for the LiveOps platform/admin HTTP API used by the seed
|
|
||||||
// and by tests' admin-side assertions. Retries network errors and 5xx with backoff;
|
|
||||||
// fails fast on 4xx.
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -54,8 +50,8 @@ export function createAdminClient(baseUrl: string): AdminClient {
|
|||||||
if (!res.ok) throw new ApiError(res.status, path, text);
|
if (!res.ok) throw new ApiError(res.status, path, text);
|
||||||
return (text ? JSON.parse(text) : undefined) as T;
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) throw err; // 4xx — don't retry
|
if (err instanceof ApiError) throw err;
|
||||||
lastErr = err; // network error — retry
|
lastErr = err;
|
||||||
await sleep(300 * (attempt + 1));
|
await sleep(300 * (attempt + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
// Vitest globalSetup for the production e2e suite.
|
|
||||||
//
|
|
||||||
// Setup: seeds a fresh project on prod (unless RUDDER_E2E_PROJECT_KEY is provided) and
|
|
||||||
// writes .e2e-prod.local.json for the tests to read.
|
|
||||||
// Teardown: deletes the seeded project (default). Set RUDDER_E2E_KEEP=1 to keep it.
|
|
||||||
|
|
||||||
import { runSeed, deleteProject } from './seed.js';
|
import { runSeed, deleteProject } from './seed.js';
|
||||||
import { readArtifact, writeArtifact, removeArtifact } from './artifact.js';
|
import { readArtifact, writeArtifact, removeArtifact } from './artifact.js';
|
||||||
import type { SeedArtifact } from './types.js';
|
import type { SeedArtifact } from './types.js';
|
||||||
@@ -11,15 +5,12 @@ import type { SeedArtifact } from './types.js';
|
|||||||
export default async function setup(): Promise<() => Promise<void>> {
|
export default async function setup(): Promise<() => Promise<void>> {
|
||||||
const baseUrl = process.env.RUDDER_E2E_BASE_URL ?? 'https://api.rudder.build';
|
const baseUrl = process.env.RUDDER_E2E_BASE_URL ?? 'https://api.rudder.build';
|
||||||
|
|
||||||
// Externally-provided project: skip seeding, just verify an artifact/env exists.
|
|
||||||
if (process.env.RUDDER_E2E_PROJECT_KEY) {
|
if (process.env.RUDDER_E2E_PROJECT_KEY) {
|
||||||
const existing = readArtifact();
|
const existing = readArtifact();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
writeArtifact({
|
writeArtifact({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
projectKey: process.env.RUDDER_E2E_PROJECT_KEY,
|
projectKey: process.env.RUDDER_E2E_PROJECT_KEY,
|
||||||
// Remaining fields are unknown for an external project; tests relying on the
|
|
||||||
// seeded dataset/admin token will require a real seed run.
|
|
||||||
} as unknown as SeedArtifact);
|
} as unknown as SeedArtifact);
|
||||||
}
|
}
|
||||||
return async () => {};
|
return async () => {};
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
// Test-side helpers: load the seed artifact and build SDK clients against prod.
|
|
||||||
|
|
||||||
import { RudderClient, createClient } from '../../../src/client/RudderClient.js';
|
import { RudderClient, createClient } from '../../../src/client/RudderClient.js';
|
||||||
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
||||||
import { readArtifact } from './artifact.js';
|
import { readArtifact } from './artifact.js';
|
||||||
import type { SeedArtifact } from './types.js';
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
|
|
||||||
export function loadArtifact(): SeedArtifact {
|
export function loadArtifact(): SeedArtifact {
|
||||||
const file = readArtifact();
|
const file = readArtifact();
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -37,7 +34,6 @@ export function createInMemoryTokenStore(): TokenStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A client with no auth yet (in-memory tokens). */
|
|
||||||
export function makeProdClient(
|
export function makeProdClient(
|
||||||
artifact: SeedArtifact = loadArtifact(),
|
artifact: SeedArtifact = loadArtifact(),
|
||||||
): RudderClient {
|
): RudderClient {
|
||||||
@@ -48,7 +44,6 @@ export function makeProdClient(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
|
|
||||||
export async function freshPlayer(
|
export async function freshPlayer(
|
||||||
artifact: SeedArtifact = loadArtifact(),
|
artifact: SeedArtifact = loadArtifact(),
|
||||||
): Promise<RudderClient> {
|
): Promise<RudderClient> {
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// Production seed: registers an operator and creates a project. Creating a
|
|
||||||
// project provisions staging + prod environments; the prod SDK key is what
|
|
||||||
// the runtime SDK sends as projectKey.
|
|
||||||
|
|
||||||
import { createAdminClient } from './adminClient.js';
|
import { createAdminClient } from './adminClient.js';
|
||||||
import type { SeedArtifact } from './types.js';
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
// Shared types for the production e2e harness.
|
|
||||||
|
|
||||||
/** Persisted seed output, written to .e2e-prod.local.json and read by tests. */
|
|
||||||
export interface SeedArtifact {
|
export interface SeedArtifact {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
/** Operator (platform) bearer token — used for admin calls (wallet grant, player details). */
|
|
||||||
adminToken: string;
|
adminToken: string;
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, it, expect, beforeAll } from 'vitest';
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
import { loadArtifact } from './_setup/harness.js';
|
import { loadArtifact } from './_setup/harness.js';
|
||||||
|
|
||||||
// Smoke gate: if egress to prod is blocked or the gateway is down, fail fast here.
|
|
||||||
describe('e2e-prod: health', () => {
|
describe('e2e-prod: health', () => {
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { createTestClient } from '../helpers/createClient.js';
|
import { createTestClient } from '../helpers/createClient.js';
|
||||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||||
// Checked-in output of `rudder client generate --lang ts` for
|
|
||||||
// test/fixtures/leaderboards.manifest.json; `npm run typecheck` proves the
|
|
||||||
// generated factory accepts RudderClient.
|
|
||||||
import { modules, type LeaderboardsSubmitError } from '../fixtures/rudder.modules.js';
|
import { modules, type LeaderboardsSubmitError } from '../fixtures/rudder.modules.js';
|
||||||
import { RudderModuleError } from '../../src/client/RudderError.js';
|
import { RudderModuleError } from '../../src/client/RudderError.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { TokenStore } from '../../src/token/TokenStore.js';
|
import type { TokenStore } from '../../src/token/TokenStore.js';
|
||||||
|
|
||||||
/** In-memory token store for testing. */
|
|
||||||
export function createFakeTokenStore(): TokenStore {
|
export function createFakeTokenStore(): TokenStore {
|
||||||
let access: string | null = null;
|
let access: string | null = null;
|
||||||
let refresh: string | null = null;
|
let refresh: string | null = null;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { RudderClient, createClient } from '../../src/client/RudderClient.js';
|
|||||||
import type { RudderClientOptions } from '../../src/client/RudderClientOptions.js';
|
import type { RudderClientOptions } from '../../src/client/RudderClientOptions.js';
|
||||||
import { createFakeTokenStore } from './FakeTokenStore.js';
|
import { createFakeTokenStore } from './FakeTokenStore.js';
|
||||||
|
|
||||||
/** Creates a RudderClient with test-friendly defaults and a fake token store. */
|
|
||||||
export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient {
|
export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient {
|
||||||
return createClient({
|
return createClient({
|
||||||
baseUrl: 'https://api.test.rudder.build',
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
|||||||
Reference in New Issue
Block a user