import { EventPayload, MixanErrorResponse, ProfilePayload, } from '@mixan/types' type NewMixanOptions = { url: string clientId: string clientSecret: string batchInterval?: number maxBatchSize?: number sessionTimeout?: number verbose?: boolean saveProfileId: (profiId: string) => void getProfileId: () => (string | null) removeProfileId: () => void } type MixanOptions = Required class Fetcher { private url: string private clientId: string private clientSecret: string private logger: (...args: any[]) => void constructor(options: MixanOptions) { this.url = options.url this.clientId = options.clientId this.clientSecret = options.clientSecret this.logger = options.verbose ? console.log : () => {} } post( path: string, data: Record = {}, options: RequestInit = {} ): Promise { const url = `${this.url}${path}` this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2)) return fetch(url, { headers: { ['mixan-client-id']: this.clientId, ['mixan-client-secret']: this.clientSecret, 'Content-Type': 'application/json', }, method: 'POST', body: JSON.stringify(data), ...options, }) .then(async (res) => { const response = await res.json() as (MixanErrorResponse | Response) if(!response) { return null } if (typeof response === 'object' && 'status' in response && response.status === 'error') { this.logger( `Mixan request failed: [${options.method || 'POST'}] ${url}`, JSON.stringify(response, null, 2) ) return null } return response as Response }) .catch(() => { this.logger( `Mixan request failed: [${options.method || 'POST'}] ${url}` ) return null }) } } class Batcher { queue: T[] = [] timer?: NodeJS.Timeout callback: (queue: T[]) => void maxBatchSize: number batchInterval: number constructor(options: MixanOptions, callback: (queue: T[]) => void) { this.callback = callback this.maxBatchSize = options.maxBatchSize this.batchInterval = options.batchInterval } add(payload: T) { this.queue.push(payload) this.flush() } flush() { if (this.timer) { clearTimeout(this.timer) } if (this.queue.length === 0) { return } if (this.queue.length >= this.maxBatchSize) { this.send() return } this.timer = setTimeout(this.send.bind(this), this.batchInterval) } send() { if (this.timer) { clearTimeout(this.timer) } if (this.queue.length > 0) { this.callback(this.queue) this.queue = [] } } } export class Mixan { private fetch: Fetcher private eventBatcher: Batcher private profileId?: string private options: MixanOptions private logger: (...args: any[]) => void private globalProperties: Record = {} private lastEventAt?: string private lastScreenViewAt?: string constructor(options: NewMixanOptions) { this.logger = options.verbose ? console.log : () => {} this.options = { sessionTimeout: 1000 * 60 * 30, verbose: false, batchInterval: 10000, maxBatchSize: 10, ...options, } this.fetch = new Fetcher(this.options) this.eventBatcher = new Batcher(this.options, (queue) => { this.fetch.post( '/events', queue.map((item) => ({ ...item, properties: { ...this.globalProperties, ...item.properties, }, profileId: item.profileId || this.profileId || null, })) ) }) } timestamp() { return new Date().toISOString() } init() { this.logger('Mixan: Init') this.setAnonymousUser() } event(name: string, properties: Record = {}) { const now = new Date() const isSessionStart = now.getTime() - new Date(this.lastEventAt ?? '1970-01-01').getTime() > this.options.sessionTimeout if (isSessionStart) { this.logger('Mixan: Session start') this.eventBatcher.add({ name: 'session_start', time: this.timestamp(), properties: {}, profileId: this.profileId || null, }) } this.logger('Mixan: Queue event', name) this.eventBatcher.add({ name, properties, time: this.timestamp(), profileId: this.profileId || null, }) this.lastEventAt = this.timestamp() } private async setAnonymousUser(retryCount: number = 0) { const profileId = this.options.getProfileId() if (profileId) { this.profileId = profileId this.logger('Mixan: Use existing profile', this.profileId) } else { const res = await this.fetch.post<{id: string}>('/profiles') if(res) { this.profileId = res.id this.options.saveProfileId(res.id) this.logger('Mixan: Create new profile', this.profileId) } else if(retryCount < 2) { setTimeout(() => { this.setAnonymousUser(retryCount + 1) }, 500); } else { this.logger('Mixan: Failed to create new profile') } } } async setUser(profile: ProfilePayload) { if (!this.profileId) { return this.logger('Mixan: Set user failed, no profileId') } this.logger('Mixan: Set user', profile) await this.fetch.post(`/profiles/${this.profileId}`, profile, { method: 'PUT', }) } async setUserProperty(name: string, value: any) { if (!this.profileId) { return this.logger('Mixan: Set user property, no profileId') } this.logger('Mixan: Set user property', name, value) await this.fetch.post(`/profiles/${this.profileId}`, { properties: { [name]: value, }, }) } async setGlobalProperties(properties: Record) { this.logger('Mixan: Set global properties', properties) this.globalProperties = properties ?? {} } async increment(name: string, value: number = 1) { if (!this.profileId) { this.logger('Mixan: Increment failed, no profileId') return } this.logger('Mixan: Increment user property', name, value) await this.fetch.post( `/profiles/${this.profileId}/increment`, { name, value, }, { method: 'PUT', } ) } async decrement(name: string, value: number = 1) { if (!this.profileId) { this.logger('Mixan: Decrement failed, no profileId') return } this.logger('Mixan: Decrement user property', name, value) await this.fetch.post( `/profiles/${this.profileId}/decrement`, { name, value, }, { method: 'PUT', } ) } async screenView(route: string, _properties?: Record) { const properties = _properties ?? {} const now = new Date() if (this.lastScreenViewAt) { const last = new Date(this.lastScreenViewAt) const diff = now.getTime() - last.getTime() this.logger(`Mixan: Screen view duration: ${diff}ms`) properties['duration'] = diff } this.lastScreenViewAt = now.toISOString() await this.event('screen_view', { ...properties, route, }) } flush() { this.logger('Mixan: Flushing events queue') this.eventBatcher.send() this.lastScreenViewAt = undefined } clear() { this.logger('Mixan: Clear, send remaining events and remove profileId') this.eventBatcher.send() this.options.removeProfileId() this.profileId = undefined this.setAnonymousUser() } }