import { CONSTANTS } from "../constants"; import type { StorageManager } from "../storage"; import type { SsoInitResponse } from "../types"; import { createId } from "../utils/ids"; import type { Logger } from "../utils/logger"; export class SessionManager { private orgId: string | null = null; private projectId: string | null = null; private idleTimeoutMs = CONSTANTS.defaults.idleTimeoutMs; private sessionExpiryMs = CONSTANTS.defaults.sessionExpiryMs; private renewalPromise: Promise | null = null; private isEmittingSessionStart = false; constructor( private readonly storage: StorageManager, private readonly logger: Logger, private readonly onSessionStart?: (sessionId: string, visitorId: string) => Promise ) {} configure(ssoResponse: SsoInitResponse): void { this.orgId = ssoResponse.orgId; this.projectId = ssoResponse.projectId; this.idleTimeoutMs = this.normalizeDurationMs( ssoResponse.config?.idleTimeoutMs, CONSTANTS.defaults.idleTimeoutMs ); this.sessionExpiryMs = this.normalizeDurationMs( ssoResponse.config?.sessionExpiryMs, CONSTANTS.defaults.sessionExpiryMs ); this.logger.info("SDK configured for project", { orgId: this.orgId, projectId: this.projectId, idleTimeoutMs: this.idleTimeoutMs, sessionExpiryMs: this.sessionExpiryMs, }); } async init(): Promise { await this.ensureInstallationId(); await this.ensureVisitorId(); await this.renewSessionIfNeeded(); await this.updateActivity(); } async renewSessionIfNeeded(): Promise { if (this.renewalPromise) { if (this.isEmittingSessionStart) { return; } await this.renewalPromise; return; } this.renewalPromise = this.renewSessionIfNeededInternal().finally(() => { this.renewalPromise = null; }); await this.renewalPromise; } private async renewSessionIfNeededInternal(): Promise { const [lastActivity, sessionStart, existingSessionId] = await Promise.all([ this.storage.get(CONSTANTS.storageKeys.lastActivity), this.storage.get(CONSTANTS.storageKeys.sessionStart), this.storage.get(CONSTANTS.storageKeys.sessionId), ]); const now = Date.now(); let isExpired = !existingSessionId; if (lastActivity && now - Number.parseInt(lastActivity, 10) > this.idleTimeoutMs) { isExpired = true; } if (sessionStart && now - Number.parseInt(sessionStart, 10) > this.sessionExpiryMs) { isExpired = true; } if (!isExpired) { return; } const newSessionId = createId(); await Promise.all([ this.storage.set(CONSTANTS.storageKeys.sessionId, newSessionId), this.storage.set(CONSTANTS.storageKeys.sessionStart, String(now)), ]); this.logger.info("Session initialized or renewed", { oldSessionId: existingSessionId, newSessionId, }); if (this.onSessionStart && !this.isEmittingSessionStart) { this.isEmittingSessionStart = true; try { await this.onSessionStart(newSessionId, await this.getVisitorId()); } finally { this.isEmittingSessionStart = false; } } } async updateActivity(): Promise { await this.storage.set(CONSTANTS.storageKeys.lastActivity, String(Date.now())); } async clearSession(): Promise { await Promise.all([ this.storage.remove(CONSTANTS.storageKeys.sessionId), this.storage.remove(CONSTANTS.storageKeys.sessionStart), this.storage.remove(CONSTANTS.storageKeys.lastActivity), ]); this.logger.info("Session cleared"); } async startNewSession(): Promise { this.logger.info("Session reset requested"); await this.clearSession(); await this.renewSessionIfNeeded(); await this.updateActivity(); } async getInstallationId(): Promise { const installationId = await this.storage.get(CONSTANTS.storageKeys.installationId); if (installationId) { return installationId; } const newInstallationId = createId(); await this.storage.set(CONSTANTS.storageKeys.installationId, newInstallationId); this.logger.warn("Recovered missing installation ID with a new value", { installationId: newInstallationId, }); return newInstallationId; } async getVisitorId(): Promise { const visitorId = await this.storage.get(CONSTANTS.storageKeys.visitorId); if (visitorId) { return visitorId; } const deviceScopedVisitorId = await this.getInstallationId(); await this.storage.set(CONSTANTS.storageKeys.visitorId, deviceScopedVisitorId); this.logger.warn("Recovered missing visitor ID from device-scoped installation ID", { visitorId: deviceScopedVisitorId, }); return deviceScopedVisitorId; } async getSessionId(): Promise { if (this.isEmittingSessionStart) { const activeSessionId = await this.storage.get(CONSTANTS.storageKeys.sessionId); if (activeSessionId) { return activeSessionId; } } await this.renewSessionIfNeeded(); const sessionId = await this.storage.get(CONSTANTS.storageKeys.sessionId); if (sessionId) { return sessionId; } const newSessionId = createId(); await Promise.all([ this.storage.set(CONSTANTS.storageKeys.sessionId, newSessionId), this.storage.set(CONSTANTS.storageKeys.sessionStart, String(Date.now())), ]); this.logger.warn("Recovered missing session ID with a new value", { sessionId: newSessionId, }); return newSessionId; } getOrgId(): string | null { return this.orgId; } getProjectId(): string | null { return this.projectId; } private async ensureInstallationId(): Promise { await this.getInstallationId(); } private async ensureVisitorId(): Promise { await this.getVisitorId(); } private normalizeDurationMs(value: number | undefined, fallback: number): number { if (value === undefined || !Number.isFinite(value) || value < 60_000) { return fallback; } return value; } }