@splitsoftware/splitio-commons
Version:
Split JavaScript SDK common components
143 lines (126 loc) • 6.32 kB
text/typescript
/* eslint-disable no-undef */
import { ISignalListener } from './types';
import { IRecorderCacheSync, IStorageSync } from '../storages/types';
import { fromImpressionsCollector } from '../sync/submitters/impressionsSubmitter';
import { fromImpressionCountsCollector } from '../sync/submitters/impressionCountsSubmitter';
import { IResponse, IServiceApi } from '../services/types';
import { ISettings } from '../types';
import SplitIO from '../../types/splitio';
import { ImpressionsPayload } from '../sync/submitters/types';
import { objectAssign } from '../utils/lang/objectAssign';
import { CLEANUP_REGISTERING, CLEANUP_DEREGISTERING, SUBMITTERS_PUSH_PAGE_HIDDEN } from '../logger/constants';
import { ISyncManager } from '../sync/types';
import { isConsentGranted } from '../consent';
import { ISdkFactoryContextSync } from '../sdkFactory/types';
const VISIBILITYCHANGE_EVENT = 'visibilitychange';
const PAGEHIDE_EVENT = 'pagehide';
const EVENT_NAME = 'for visibilitychange and pagehide events.';
/**
* We'll listen for events over the window object.
*/
export class BrowserSignalListener implements ISignalListener {
private syncManager: ISyncManager | undefined;
private settings: ISettings;
private storage: IStorageSync;
private serviceApi: IServiceApi;
private fromImpressionsCollector: (data: SplitIO.ImpressionDTO[]) => ImpressionsPayload;
constructor({ syncManager, settings, storage, serviceApi, entityType }: ISdkFactoryContextSync) {
this.syncManager = syncManager;
this.settings = settings;
this.storage = storage;
this.serviceApi = serviceApi;
this.flushData = this.flushData.bind(this);
this.flushDataIfHidden = this.flushDataIfHidden.bind(this);
this.fromImpressionsCollector = fromImpressionsCollector.bind(undefined, settings.core.labelsEnabled, entityType);
}
/**
* start method.
* Called when SplitFactory is initialized, it adds event listeners to close streaming and flush impressions and events.
*/
start() {
this.settings.log.debug(CLEANUP_REGISTERING, [EVENT_NAME]);
if (typeof document !== 'undefined' && document.addEventListener) {
// Flush data whenever the page is hidden or unloaded.
document.addEventListener(VISIBILITYCHANGE_EVENT, this.flushDataIfHidden);
}
if (typeof window !== 'undefined' && window.addEventListener) {
// Some browsers, like Safari, does not fire the `visibilitychange` event when the page is being unloaded. Therefore, we also flush data in the `pagehide` event.
// If both events are triggered, the latter will find the storage empty, so no duplicate data will be submitted.
window.addEventListener(PAGEHIDE_EVENT, this.flushData);
}
}
/**
* stop method.
* Called when client is destroyed, it removes event listeners.
*/
stop() {
this.settings.log.debug(CLEANUP_DEREGISTERING, [EVENT_NAME]);
if (typeof document !== 'undefined' && document.removeEventListener) {
document.removeEventListener(VISIBILITYCHANGE_EVENT, this.flushDataIfHidden);
}
if (typeof window !== 'undefined' && window.removeEventListener) {
window.removeEventListener(PAGEHIDE_EVENT, this.flushData);
}
}
/**
* flushData method.
* Called when pagehide event is triggered. It flushed remaining impressions and events to the backend,
* using beacon API if possible, or falling back to regular post transport.
*/
flushData() {
if (!this.syncManager) return; // In consumer mode there is not sync manager and data to flush
const { events, telemetry } = this.settings.urls;
// Flush impressions & events data if there is user consent
if (isConsentGranted(this.settings)) {
const extraMetadata = {
// sim stands for Sync/Split Impressions Mode
sim: this.settings.sync.impressionsMode
};
this._flushData(events + '/testImpressions/beacon', this.storage.impressions, this.serviceApi.postTestImpressionsBulk, this.fromImpressionsCollector, extraMetadata);
this._flushData(events + '/events/beacon', this.storage.events, this.serviceApi.postEventsBulk);
this._flushData(events + '/testImpressions/count/beacon', this.storage.impressionCounts, this.serviceApi.postTestImpressionsCount, fromImpressionCountsCollector);
this._flushData(telemetry + '/v1/keys/cs/beacon', this.storage.uniqueKeys, this.serviceApi.postUniqueKeysBulkCs);
}
// Flush telemetry data
if (this.storage.telemetry) this._flushData(telemetry + '/v1/metrics/usage/beacon', this.storage.telemetry, this.serviceApi.postMetricsUsage);
}
flushDataIfHidden() {
// Precondition: document defined
if (document.visibilityState === 'hidden') this.flushData(); // On a 'visibilitychange' event, flush data if state is hidden
}
private _flushData<T>(url: string, cache: IRecorderCacheSync<T>, postService: (body: string) => Promise<IResponse>, fromCacheToPayload?: (cacheData: T) => any, extraMetadata?: {}) {
// if there is data in cache, send it to backend
if (!cache.isEmpty()) {
const dataPayload = fromCacheToPayload ? fromCacheToPayload(cache.pop()) : cache.pop();
if (!this._sendBeacon(url, dataPayload, extraMetadata)) {
postService(JSON.stringify(dataPayload)).catch(() => { }); // no-op to handle possible promise rejection
}
this.settings.log.debug(SUBMITTERS_PUSH_PAGE_HIDDEN, [cache.name]);
}
}
/**
* _sendBeacon method.
* Util method that check if beacon API is available, build the payload and send it.
* Returns true if beacon API was used successfully, false otherwise.
*/
private _sendBeacon(url: string, data: any, extraMetadata?: {}) {
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
const json = {
entries: data,
token: this.settings.core.authorizationKey,
sdk: this.settings.version
};
// Extend with endpoint specific metadata where needed
if (extraMetadata) objectAssign(json, extraMetadata);
// Stringify the payload
const payload = JSON.stringify(json);
// https://xgwang.me/posts/you-may-not-know-beacon/#it-may-throw-error%2C-be-sure-to-catch
try {
return navigator.sendBeacon(url, payload);
} catch (e) {
return false;
}
}
return false;
}
}