import { CONSTANTS } from "../constants"; import type { StorageManager } from "../storage"; import type { EventPayload, NetworkClient, PiiData, PiiEncryptor, } from "../types"; import { delay } from "../utils/time"; import type { Logger } from "../utils/logger"; interface TransportConfig { endpoint: string; apiKey: string; jwt: string; orgId: string; projectId: string; piiPublicKey?: string; fetcher: NetworkClient; piiEncryptor?: PiiEncryptor; onError?: (error: Error) => void | Promise; onJwtRefresh?: () => Promise; onReinit?: () => Promise; } const MAX_RETRIES = 4; const INITIAL_RETRY_BACKOFF_MS = 1000; const MAX_RETRY_BACKOFF_MS = 60_000; type DispatchResult = "delivered" | "retryable-failure" | "requeue" | "retired"; export class DataTransport { private queue: EventPayload[] = []; private batchTimer: ReturnType | null = null; private isFlushing = false; private hydrated = false; private pendingPii: PiiData | null = null; private encryptedPiiBlob: string | null = null; private eventTimestamps: number[] = []; private rateLimitDropCount = 0; private disposed = false; private retryAvailableAt = 0; private retryDelayMs = INITIAL_RETRY_BACKOFF_MS; private readonly hydrationPromise: Promise; constructor( private config: TransportConfig, private readonly storage: StorageManager, private readonly logger: Logger, private readonly batchSize: number, private readonly batchInterval: number, private readonly maxEventsPerMinute: number ) { this.hydrationPromise = this.loadQueueData(); } async ready(): Promise { await this.hydrationPromise; } updateConfig(config: TransportConfig): void { this.config = config; } destroy(): void { this.disposed = true; if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; } } updateJwt(jwt: string): void { this.config = { ...this.config, jwt }; } getJwt(): string { return this.config.jwt; } setPii(data: PiiData | null): void { if (JSON.stringify(data) === JSON.stringify(this.pendingPii)) { return; } this.pendingPii = data; this.encryptedPiiBlob = null; this.logger.info(data ? "PII payload staged for encryption" : "PII payload cleared"); } async enqueue(event: EventPayload, scheduleSend = true): Promise { await this.ready(); if (this.disposed) { this.logger.warn("Skipping enqueue on retired transport", { eventName: event.event_name, eventId: event.id, }); return; } const now = Date.now(); const windowStart = now - 60_000; this.eventTimestamps = this.eventTimestamps.filter((timestamp) => timestamp > windowStart); if ( this.maxEventsPerMinute > 0 && this.eventTimestamps.length >= this.maxEventsPerMinute ) { this.rateLimitDropCount += 1; this.logger.warn("Rate limit reached. Dropping event.", { eventName: event.event_name, maxEventsPerMinute: this.maxEventsPerMinute, }); return; } const properties = { ...(event.properties ?? {}) }; if (this.rateLimitDropCount > 0) { properties.rateLimitExceeded = this.rateLimitDropCount; this.rateLimitDropCount = 0; } this.eventTimestamps.push(now); this.queue.push({ ...event, properties, }); await this.persistQueue(); this.logger.info("Queued event", { eventName: event.event_name, eventId: event.id, queueSize: this.queue.length, scheduleSend, }); if (scheduleSend) { this.scheduleSend(); } } flush(): void { if (this.disposed) { this.logger.info("Flush skipped for retired transport"); return; } this.logger.info("Flush requested"); void this.flushInternal(); } async sendImmediately(event: EventPayload): Promise { await this.ready(); if (this.disposed) { throw new Error("Cannot send event on retired transport"); } this.logger.info("Sending standalone event", { eventName: event.event_name, eventId: event.id, endpoint: this.config.endpoint, }); for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { if (attempt > 0) { await delay(Math.min(1000 * 2 ** (attempt - 1), 30_000)); } const result = await this.dispatchBatch([event]); if (result === "delivered") { this.logger.info("Standalone event delivered", { eventName: event.event_name, eventId: event.id, attempt: attempt + 1, }); return; } if (result === "requeue" || result === "retired") { break; } } throw new Error(`Failed to send standalone event: ${event.event_name}`); } async flushAndWait( timeoutMs = CONSTANTS.defaults.flushTimeoutMs ): Promise { await this.ready(); if (this.disposed) { this.logger.info("flushAndWait skipped for retired transport"); return; } this.logger.info("flushAndWait started", { queueSize: this.queue.length, timeoutMs, }); if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; } const deadline = Date.now() + timeoutMs; while (this.isFlushing && Date.now() < deadline) { await delay(50); } while (this.queue.length > 0 && Date.now() < deadline) { const batch = this.queue.splice(0, this.batchSize); const result = await this.dispatchBatch(batch); if (result === "retired") { this.logger.warn( "flushAndWait stopped because transport was retired during auth recovery", { batchSize: batch.length } ); return; } if (result !== "delivered") { this.queue.unshift(...batch); break; } } await this.persistQueue(); this.logger.info("flushAndWait complete", { queueSize: this.queue.length, }); } private async loadQueueData(): Promise { try { const storedQueue = await this.storage.get(CONSTANTS.storageKeys.queue); if (storedQueue) { const parsed = JSON.parse(storedQueue) as EventPayload[]; if (Array.isArray(parsed)) { this.queue = parsed; } } this.logger.info("Loaded queued events", { queueSize: this.queue.length }); } catch (error) { this.logger.warn("Failed to load queued events", error); } finally { this.hydrated = true; } } private async persistQueue(): Promise { if (!this.hydrated) { return; } if (this.queue.length === 0) { await this.storage.remove(CONSTANTS.storageKeys.queue); return; } await this.storage.set( CONSTANTS.storageKeys.queue, JSON.stringify(this.queue) ); } private scheduleSend(): void { if (this.disposed || this.queue.length === 0) { return; } const retryDelayMs = Math.max(this.retryAvailableAt - Date.now(), 0); if (retryDelayMs > 0) { if (!this.batchTimer) { this.logger.info("Scheduled event retry", { queueSize: this.queue.length, retryInMs: retryDelayMs, }); this.batchTimer = setTimeout(() => { this.batchTimer = null; void this.flushInternal(); }, retryDelayMs); } return; } if (this.queue.length >= this.batchSize) { this.logger.info("Batch size reached. Flushing now.", { queueSize: this.queue.length, batchSize: this.batchSize, }); void this.flushInternal(); return; } if (this.batchTimer) { return; } this.logger.info("Scheduled event flush", { queueSize: this.queue.length, flushIntervalMs: this.batchInterval, }); this.batchTimer = setTimeout(() => { this.batchTimer = null; void this.flushInternal(); }, this.batchInterval); } private async flushInternal(): Promise { await this.ready(); if (this.disposed) { this.logger.info("Flush skipped for retired transport"); return; } if (this.isFlushing || this.queue.length === 0) { this.logger.info("Flush skipped", { isFlushing: this.isFlushing, queueSize: this.queue.length, }); return; } if (Date.now() < this.retryAvailableAt) { this.scheduleSend(); return; } if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; } const batch = this.queue.splice(0, this.batchSize); this.isFlushing = true; this.logger.info("Flushing event batch", { batchSize: batch.length, remainingQueueSize: this.queue.length, }); try { for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { if (attempt > 0) { await delay(Math.min(1000 * 2 ** (attempt - 1), 30_000)); } const result = await this.dispatchBatch(batch); if (result === "delivered") { this.resetRetryBackoff(); this.logger.info("Event batch delivered", { batchSize: batch.length, attempt: attempt + 1, }); return; } if (result === "retired") { this.logger.warn( "Event batch handed off after SDK reinitialization", { batchSize: batch.length } ); return; } if (result === "requeue") { this.queue.unshift(...batch); await this.persistQueue(); this.deferNextRetry(); this.logger.warn("Event batch requeued", { batchSize: batch.length, queueSize: this.queue.length, retryInMs: Math.max(this.retryAvailableAt - Date.now(), 0), }); return; } } this.queue.unshift(...batch); await this.persistQueue(); this.deferNextRetry(); this.logger.warn("Max retries reached. Event batch kept for retry.", { batchSize: batch.length, queueSize: this.queue.length, retryInMs: Math.max(this.retryAvailableAt - Date.now(), 0), }); } finally { this.isFlushing = false; if (!this.disposed && this.queue.length > 0) { this.scheduleSend(); } } } private async dispatchBatch(batch: EventPayload[]): Promise { try { const piiBlob = await this.getEncryptedPii(); const body = JSON.stringify(this.buildBatchPayload(batch, piiBlob)); this.logger.info("Sending event batch", { batchSize: batch.length, endpoint: this.config.endpoint, hasPii: Boolean(piiBlob), }); const response = await this.config.fetcher(this.config.endpoint, { method: "POST", headers: this.getHeaders(), body, }); if (response.ok) { await this.persistQueue(); this.logger.info("Event batch accepted by ingestion API", { batchSize: batch.length, status: response.status, }); return "delivered"; } if (response.status === 401) { const responseJson = (await this.tryJson(response)) as { code?: string } | null; if (responseJson?.code === "JWT_EXPIRED" && this.config.onJwtRefresh) { this.logger.warn("JWT expired. Attempting refresh."); const newJwt = await this.config.onJwtRefresh(); if (newJwt) { this.updateJwt(newJwt); this.logger.info("JWT refreshed. Retrying event batch."); return this.dispatchBatch(batch); } this.logger.warn("JWT refresh returned no token. Reinitializing SDK."); this.destroy(); await this.config.onReinit?.(); return "retired"; } } const error = new Error(`Failed to send events: HTTP ${response.status}`); await this.reportError(error); return "retryable-failure"; } catch (error) { await this.reportError(error); return "retryable-failure"; } } private async reportError(error: unknown): Promise { const normalized = error instanceof Error ? error : new Error(String(error)); this.logger.error("Data transport error", normalized); await this.config.onError?.(normalized); } private deferNextRetry(): void { const retryDelayMs = this.retryDelayMs; this.retryAvailableAt = Date.now() + retryDelayMs; this.retryDelayMs = Math.min(retryDelayMs * 2, MAX_RETRY_BACKOFF_MS); } private resetRetryBackoff(): void { this.retryAvailableAt = 0; this.retryDelayMs = INITIAL_RETRY_BACKOFF_MS; } private async tryJson(response: Response): Promise { try { return await response.clone().json(); } catch { return null; } } private buildBatchPayload(events: EventPayload[], piiBlob: string | null) { const userId = events.find((event) => event.userId)?.userId; const phone = events.find((event) => event.phone)?.phone; return { orgId: this.config.orgId, projectId: this.config.projectId, ...(userId ? { userId } : {}), ...(phone ? { phone } : {}), ...(piiBlob ? { pii: piiBlob } : {}), batch: events.map((event) => ({ eventName: event.event_name, eventType: CONSTANTS.eventType, timestamp: event.timestamp, metadata: { visitorId: event.visitor_id, installationId: event.installation_id, sessionId: event.session_id, context: event.context, ...event.properties, ...(event.userId ? { userId: event.userId } : {}), ...(event.phone ? { phone: event.phone } : {}), }, })), }; } private getHeaders(): Record { return { ...CONSTANTS.requestHeaders, "Content-Type": "application/json", Authorization: `Bearer ${this.config.jwt}`, "x-api-key": this.config.apiKey, }; } private async getEncryptedPii(): Promise { if (!this.pendingPii || !this.config.piiPublicKey) { return null; } if (this.encryptedPiiBlob) { return this.encryptedPiiBlob; } if (!this.config.piiEncryptor) { this.logger.warn( "PII provided but no encryptor configured. Pass piiEncryptor or enable WebCrypto in React Native." ); return null; } this.encryptedPiiBlob = await this.config.piiEncryptor( this.config.piiPublicKey, this.pendingPii ); return this.encryptedPiiBlob; } }