chore: drop comments that restate the code

This commit is contained in:
edmand46
2026-09-29 16:43:15 +03:00
parent 17a55888a5
commit 4a167df587
25 changed files with 16 additions and 350 deletions
-79
View File
@@ -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 { getOrCreateDeviceId } from '../device/DeviceId.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 interface LoginWithDeviceOptions {
/** Player's region code (default: "global"). */
region?: string;
/** Player's language code (default: "en"). */
language?: string;
/** Optional player nickname; omitted from the request when not set. */
nickname?: string;
}
export interface LoginWithCustomOptions {
/** Arbitrary payload forwarded to the developer's custom auth webhook. */
customData: Record<string, unknown>;
/** Name of the custom auth provider; omitted means the default provider. */
provider?: string;
/** Player's region code (default: "global"). */
region?: string;
/** Player's language code (default: "en"). */
language?: string;
/** Optional player nickname; omitted from the request when not set. */
nickname?: string;
}
export interface LoginWithGoogleOptions {
/** Google ID token obtained from the Google sign-in flow. */
idToken: string;
/** Player's region code (default: "global"). */
region?: string;
/** Player's language code (default: "en"). */
language?: string;
/** Optional player nickname; omitted from the request when not set. */
nickname?: string;
}
export interface LoginWithAppleOptions {
/** Apple ID token (identity token) obtained from Sign in with Apple. */
idToken: string;
/** Player's region code (default: "global"). */
region?: string;
/** Player's language code (default: "en"). */
language?: string;
/** Optional player nickname; omitted from the request when not set. */
nickname?: string;
}
export interface LinkWithCustomOptions {
/** Arbitrary payload forwarded to the developer's custom auth webhook. */
customData: Record<string, unknown>;
/** Name of the custom auth provider; omitted means the default provider. */
provider?: string;
}
@@ -83,19 +57,12 @@ export class AuthService {
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
}
/** True while an access token is present in the token store. */
get isAuthenticated(): boolean {
return this.ctx.options.tokenStore.getAccessToken() !== null;
}
/**
* Subscribes to auth state changes. The listener fires immediately with the
* current state (like `SyncedState.onChange`). Returns an unsubscribe function.
*/
onAuthStateChange(listener: AuthStateListener): () => void {
this.listeners.add(listener);
// Resync with the token store — tokens may have been written externally
// since construction.
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
listener(this.state);
return () => {
@@ -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> {
const { region = 'global', language = 'en', nickname } = options;
const response = await api.loginViaDevice(this.ctx, {
@@ -130,13 +91,6 @@ export class AuthService {
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> {
const { customData, provider, region = 'global', language = 'en', nickname } = options;
const response = await api.loginViaCustom(this.ctx, {
@@ -159,11 +113,6 @@ export class AuthService {
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> {
const { idToken, region = 'global', language = 'en', nickname } = options;
const response = await api.loginViaGoogle(this.ctx, {
@@ -185,11 +134,6 @@ export class AuthService {
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> {
const { idToken, region = 'global', language = 'en', nickname } = options;
const response = await api.loginViaApple(this.ctx, {
@@ -211,26 +155,14 @@ export class AuthService {
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> {
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> {
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> {
await api.linkViaCustom(this.ctx, {
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> {
await api.unlinkIdentity(this.ctx, { provider });
}
/** Clears all stored tokens (logout). */
logout(): void {
this.ctx.options.tokenStore.clear();
this.stopRuntime();
this.setState('signed-out');
}
/**
* Called by the transport when the session is unrecoverable (refresh failed).
*
* @internal
*/
notifySessionExpired(): void {
this.setState('signed-out');
}
-14
View File
@@ -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 {
ResolvedRudderClientOptions,
RudderClientOptions,
@@ -102,7 +91,6 @@ export class RudderClient {
this.runtimeStarted = false;
}
/** @internal */
async startRuntimeAfterLogin(): Promise<void> {
if (this.runtimeStarted) {
this.invalidateAll();
@@ -112,7 +100,6 @@ export class RudderClient {
this.syncEngine.start();
}
/** @internal */
stopRuntimeAfterLogout(): void {
this.runtimeStarted = false;
this.syncEngine.stop();
@@ -132,7 +119,6 @@ export class RudderClient {
}
}
/** Creates a client. Only `projectKey` is required. */
export function createClient(options: RudderClientOptions): RudderClient {
return new RudderClient(options);
}
-14
View File
@@ -1,33 +1,19 @@
import type { TokenStore } from '../token/TokenStore.js';
export interface RudderClientOptions {
/** Project SDK key; it also selects the environment (staging or prod). */
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;
/**
* 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;
/** API base URL (default: "https://api.rudder.build"). */
baseUrl?: string;
/** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */
syncIntervalMs?: number;
}
/** Client options with all defaults resolved — what `client.options` exposes. */
export interface ResolvedRudderClientOptions extends RudderClientOptions {
baseUrl: string;
tokenStore: TokenStore;
-20
View File
@@ -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;
/** Code for client-side configuration validation failures. */
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
/** Base error class for all SDK errors. */
export class RudderError extends Error {
public readonly code?: RudderErrorCodeLike;
@@ -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 {
constructor(cause: unknown) {
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 {
constructor(
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 {
constructor(statusText: string, body: string, code?: RudderErrorCodeLike) {
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 {
declare public readonly code: C;
public readonly requestId?: string;
-8
View File
@@ -1,14 +1,6 @@
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.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 {
readonly options: ResolvedRudderClientOptions;
request<TResponse>(
-1
View File
@@ -1,4 +1,3 @@
/** UUID v4. Falls back to getRandomValues where randomUUID is missing (non-secure contexts, old runtimes). */
export function uuidv4(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
-12
View File
@@ -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';
const STORAGE_KEY = 'rudder_device_id';
/**
* Returns the stored device ID or creates a new one if none exists.
* The device ID is persisted in localStorage and survives page reloads.
*/
export function getOrCreateDeviceId(): string {
try {
const existing = localStorage.getItem(STORAGE_KEY);
if (existing) return existing;
} catch {
// localStorage unavailable — generate ephemeral ID.
}
const id = uuidv4();
try {
localStorage.setItem(STORAGE_KEY, id);
} catch {
// Storage unavailable — ID will be ephemeral for this session.
}
return id;
}
-4
View File
@@ -3,10 +3,6 @@ import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.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>> {
readonly syncKey = 'catalog';
-11
View File
@@ -8,22 +8,12 @@ import type {
} from '../generated/modules.js';
export interface ModuleCallOptions {
/** Version the caller was built against; sent as `X-Rudder-Module-Version: <module>@<version>`. */
moduleVersion?: string;
}
/**
* ModulesDomain — calls module functions. Usually reached through a client
* generated by `rudder client generate`, not directly.
*/
export class ModulesDomain {
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>(
module: string,
fn: string,
@@ -49,7 +39,6 @@ export class ModulesDomain {
try {
payload = JSON.parse(err.body);
} 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 });
}
-5
View File
@@ -3,11 +3,6 @@ import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { PlayerProfile } from '../generated/player.js';
/**
* PlayerDomain — the observable player profile (player, identities,
* balances, items).
* Synced under revision key `profile`.
*/
export class PlayerDomain extends SyncedState<PlayerProfile> {
readonly syncKey = 'profile';
-10
View File
@@ -1,6 +1,3 @@
// @rudder/sdk — Rudder client SDK for web games
// Client
export { RudderClient, createClient } from './client/RudderClient.js';
export type { RudderClientOptions } from './client/RudderClientOptions.js';
export {
@@ -13,11 +10,9 @@ export {
} from './client/RudderError.js';
export type { RudderErrorCodeLike } from './client/RudderError.js';
// Token management
export type { TokenStore } from './token/TokenStore.js';
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
// Services — types only; instances live on the client (client.auth).
export type {
AuthService,
AuthState,
@@ -29,7 +24,6 @@ export type {
LoginWithGoogleOptions,
} from './auth/AuthService.js';
// Observable state primitives
export { SyncedState } from './state/SyncedState.js';
export type {
SyncedSnapshot,
@@ -37,15 +31,11 @@ export type {
SyncedListener,
} 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 { CatalogDomain } from './domains/CatalogDomain.js';
export type { ModulesDomain, ModuleCallOptions } from './domains/ModulesDomain.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 { CatalogCurrency } from './generated/catalog.js';
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
-17
View File
@@ -6,21 +6,11 @@ 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> = {};
@@ -89,7 +79,6 @@ export class SyncEngine {
const revisions = await api.getRevisions(this.ctx);
this.applyRevisions(revisions ?? {});
} catch {
// Network/server errors must not stop the loop.
} finally {
this.polling = false;
}
@@ -97,9 +86,6 @@ export class SyncEngine {
}
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) {
@@ -115,7 +101,6 @@ export class SyncEngine {
}
private invalidate(key: string): void {
// Unknown keys have no registered domain and are ignored.
this.byKey.get(key)?.invalidate();
}
}
@@ -137,7 +122,6 @@ function persistRevisions(revisions: Record<string, number>): void {
try {
localStorage.setItem(REVISIONS_STORAGE_KEY, JSON.stringify(revisions));
} catch {
// Storage unavailable — best effort.
}
}
@@ -145,6 +129,5 @@ function clearPersistedRevisions(): void {
try {
localStorage.removeItem(REVISIONS_STORAGE_KEY);
} catch {
// Storage unavailable — silently ignore.
}
}
+1 -30
View File
@@ -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 interface SyncedSnapshot<T> {
@@ -26,7 +17,6 @@ export class SyncedState<T> {
constructor(private readonly loader: () => Promise<T>) {}
/** Current data, or undefined when not loaded yet. */
get value(): T | undefined {
return this.snapshot.value;
}
@@ -39,10 +29,6 @@ export class SyncedState<T> {
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);
@@ -51,7 +37,6 @@ export class SyncedState<T> {
};
}
/** Loads the entity if needed; deduplicates parallel loads. */
load(): Promise<T> {
if (this.snapshot.status === 'ready') {
return Promise.resolve(this.snapshot.value as T);
@@ -59,25 +44,16 @@ export class SyncedState<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() between scheduling and flushing cancels the reload
// (the entity was dropped on logout).
// A reset() between scheduling and flushing cancels the reload.
if (!this.invalidateScheduled) return;
this.invalidateScheduled = false;
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 {
this.generation++;
this.pending = undefined;
-24
View File
@@ -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 {
/** Returns the current access token, or null if not authenticated. */
getAccessToken(): string | null;
/** Returns the current refresh token, or null if not available. */
getRefreshToken(): string | null;
/** Persists access and refresh tokens after a successful login. */
saveTokens(accessToken: string, refreshToken: string): void;
/** Removes all stored tokens (logout). */
clear(): void;
}
const STORAGE_KEY_ACCESS = 'rudder_access_token';
const STORAGE_KEY_REFRESH = 'rudder_refresh_token';
/**
* Creates the default TokenStore: localStorage-backed when usable, otherwise
* a silent in-memory fallback (tokens live for the page session only).
*/
export function createDefaultTokenStore(): TokenStore {
try {
const probe = 'rudder_storage_probe';
@@ -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 {
return {
getAccessToken(): string | null {
@@ -67,7 +45,6 @@ export function createLocalStorageTokenStore(): TokenStore {
localStorage.setItem(STORAGE_KEY_ACCESS, accessToken);
localStorage.setItem(STORAGE_KEY_REFRESH, refreshToken);
} catch {
// Storage full or unavailable — silently ignore.
}
},
@@ -76,7 +53,6 @@ export function createLocalStorageTokenStore(): TokenStore {
localStorage.removeItem(STORAGE_KEY_ACCESS);
localStorage.removeItem(STORAGE_KEY_REFRESH);
} catch {
// Storage unavailable — silently ignore.
}
},
};
+13 -44
View File
@@ -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 type { TokenStore } from '../token/TokenStore.js';
import type {
@@ -15,14 +10,8 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 2;
const RETRY_BASE_DELAY_MS = 300;
/** Per-call transport options. */
export interface RequestOptions {
/** Extra headers, sent unchanged on every attempt (retries and the post-refresh retry). */
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;
}
@@ -30,28 +19,12 @@ 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. */
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>(
method: string,
path: string,
@@ -76,7 +49,6 @@ export async function request<TResponse>(
if (!response.ok) {
const bodyText = await response.text().catch(() => '');
// Try to extract machine-readable code from error body.
let code: string | undefined;
try {
const parsed = JSON.parse(bodyText);
@@ -84,7 +56,6 @@ export async function request<TResponse>(
code = parsed.code;
}
} catch {
// non-JSON body — ignore
}
if (response.status === 401) {
@@ -93,7 +64,6 @@ export async function request<TResponse>(
throw new RudderHttpError(response.status, response.statusText, bodyText, code);
}
// 204 No Content — no body to parse.
if (response.status === 204) {
return undefined as unknown as TResponse;
}
@@ -101,7 +71,6 @@ export async function request<TResponse>(
return response.json() as Promise<TResponse>;
}
/** Fetches with per-attempt timeout; retries per {@link RequestOptions.retry}. */
async function requestWithRetry(
method: 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> {
const state = (ctx.refreshState ??= {});
state.pending ??= doRefreshTokens(ctx).finally(() => {
@@ -188,8 +153,9 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
const controller = new AbortController();
const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const timer = setTimeout(() => controller.abort(), timeout);
let response: Response;
try {
const response = await fetch(url, {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -199,19 +165,22 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
} satisfies RefreshAccessTokenRequest),
signal: controller.signal,
});
if (!response.ok) return false;
const tokens = (await response.json()) as RefreshAccessTokenResponse;
if (!tokens.accessToken || !tokens.refreshToken) return false;
ctx.tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken);
return true;
} catch {
return false;
} catch (err) {
throw new RudderNetworkError(err);
} finally {
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> {
const ms = RETRY_BASE_DELAY_MS * 2 ** attempt * (0.5 + Math.random());
return new Promise((resolve) => setTimeout(resolve, ms));
-23
View File
@@ -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('&')}`;
}
+2 -6
View File
@@ -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));
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);
return (text ? JSON.parse(text) : undefined) as T;
} catch (err) {
if (err instanceof ApiError) throw err; // 4xx — don't retry
lastErr = err; // network error — retry
if (err instanceof ApiError) throw err;
lastErr = err;
await sleep(300 * (attempt + 1));
}
}
-9
View File
@@ -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 { readArtifact, writeArtifact, removeArtifact } from './artifact.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>> {
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) {
const existing = readArtifact();
if (!existing) {
writeArtifact({
baseUrl,
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);
}
return async () => {};
-5
View File
@@ -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 type { TokenStore } from '../../../src/token/TokenStore.js';
import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js';
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
export function loadArtifact(): SeedArtifact {
const file = readArtifact();
if (!file) {
@@ -37,7 +34,6 @@ export function createInMemoryTokenStore(): TokenStore {
};
}
/** A client with no auth yet (in-memory tokens). */
export function makeProdClient(
artifact: SeedArtifact = loadArtifact(),
): 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(
artifact: SeedArtifact = loadArtifact(),
): Promise<RudderClient> {
-4
View File
@@ -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 type { SeedArtifact } from './types.js';
-4
View File
@@ -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 {
baseUrl: string;
projectId: string;
projectKey: string;
/** Operator (platform) bearer token — used for admin calls (wallet grant, player details). */
adminToken: string;
adminEmail: string;
environment: string;
-1
View File
@@ -1,7 +1,6 @@
import { describe, it, expect, beforeAll } from 'vitest';
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', () => {
let baseUrl: string;
beforeAll(() => {
-3
View File
@@ -1,9 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createTestClient } from '../helpers/createClient.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 { RudderModuleError } from '../../src/client/RudderError.js';
-1
View File
@@ -1,6 +1,5 @@
import type { TokenStore } from '../../src/token/TokenStore.js';
/** In-memory token store for testing. */
export function createFakeTokenStore(): TokenStore {
let access: string | null = null;
let refresh: string | null = null;
-1
View File
@@ -2,7 +2,6 @@ import { RudderClient, createClient } from '../../src/client/RudderClient.js';
import type { RudderClientOptions } from '../../src/client/RudderClientOptions.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 {
return createClient({
baseUrl: 'https://api.test.rudder.build',