verstak-browser-extension/shared/activity-tracker.js

284 lines
10 KiB
JavaScript

(function (root) {
'use strict';
var ACTIVITY_STATE_KEY = 'verstak.domainActivity.v1';
var MAX_CHECKPOINT_GAP_MS = 10 * 60 * 1000;
var ACK_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
var MAX_ACKNOWLEDGED_IDS = 1000;
function createMemoryActivityStorage(seed) {
var state = Object.assign({}, seed || {});
return {
get: function (key) {
return Promise.resolve(state[key]);
},
set: function (key, value) {
state[key] = value;
return Promise.resolve();
}
};
}
function browserActivityStorageAdapter(browserApi) {
var storage = browserApi && browserApi.storage && browserApi.storage.local;
if (!storage) return createMemoryActivityStorage();
return {
get: function (key) {
return storage.get(key).then(function (result) { return result && result[key]; });
},
set: function (key, value) {
var patch = {};
patch[key] = value;
return storage.set(patch);
}
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function isFiniteNumber(value) {
return typeof value === 'number' && Number.isFinite(value);
}
function initialState() {
return {
activeAccumulator: {},
pendingBatches: [],
acknowledgedIds: []
};
}
function normalizedState(value) {
var state = value && typeof value === 'object' ? value : {};
return {
activeAccumulator: state.activeAccumulator && typeof state.activeAccumulator === 'object' ? state.activeAccumulator : {},
pendingBatches: Array.isArray(state.pendingBatches) ? state.pendingBatches : [],
acknowledgedIds: Array.isArray(state.acknowledgedIds) ? state.acknowledgedIds : []
};
}
function batchId(now) {
var cryptoObj = root.crypto;
if (cryptoObj && typeof cryptoObj.randomUUID === 'function') return cryptoObj.randomUUID();
return 'activity-' + String(now) + '-' + Math.random().toString(36).slice(2);
}
function toISOString(time) {
return new Date(time).toISOString();
}
function isExcludedHostname(hostname, excludedDomains) {
var normalizer = root.VerstakBrowser && root.VerstakBrowser.normalizeHostnameV1;
var canonicalHostname = typeof normalizer === 'function' ? normalizer(hostname) : '';
if (!canonicalHostname) return true;
return (Array.isArray(excludedDomains) ? excludedDomains : []).some(function (value) {
var excluded = typeof normalizer === 'function' ? normalizer(value) : '';
return excluded && (canonicalHostname === excluded || canonicalHostname.slice(-(excluded.length + 1)) === '.' + excluded);
});
}
function DomainActivityTracker(storage, sender, options) {
options = options || {};
this.storage = storage || createMemoryActivityStorage();
this.sender = typeof sender === 'function' ? sender : function () { return Promise.reject(new Error('activity sender unavailable')); };
this.maxCheckpointGapMs = isFiniteNumber(options.maxCheckpointGapMs) ? options.maxCheckpointGapMs : MAX_CHECKPOINT_GAP_MS;
this.now = typeof options.now === 'function' ? options.now : function () { return Date.now(); };
this.state = initialState();
this.enabled = false;
this.activeHostname = '';
this.serial = Promise.resolve();
}
DomainActivityTracker.prototype.enqueue = function (operation) {
var next = this.serial.then(operation, operation);
this.serial = next.catch(function () {});
return next;
};
DomainActivityTracker.prototype.initialize = function () {
var self = this;
return this.enqueue(function () {
return self.storage.get(ACTIVITY_STATE_KEY).then(function (value) {
self.state = normalizedState(value);
self.pruneAcknowledged(self.now());
return self.persist().then(function () { return self.getStateUnsafe(); });
});
});
};
DomainActivityTracker.prototype.getState = function () {
var self = this;
return this.enqueue(function () { return self.getStateUnsafe(); });
};
DomainActivityTracker.prototype.getStateUnsafe = function () {
return clone(this.state);
};
DomainActivityTracker.prototype.persist = function () {
return this.storage.set(ACTIVITY_STATE_KEY, clone(this.state));
};
DomainActivityTracker.prototype.setEnabled = function (enabled) {
var self = this;
return this.enqueue(function () {
self.enabled = enabled === true;
if (!self.enabled) {
self.activeHostname = '';
self.state.activeAccumulator = {};
self.state.pendingBatches = [];
}
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.setActiveHostname = function (hostname, active, observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
if (!self.enabled || !active || !hostname) {
self.checkpointUnsafe(now);
self.activeHostname = '';
return self.persist().then(function () { return self.getStateUnsafe(); });
}
self.checkpointUnsafe(now);
self.activeHostname = hostname;
self.ensureAccumulator(hostname, now);
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.checkpoint = function (observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
self.checkpointUnsafe(now);
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.ensureAccumulator = function (hostname, now) {
if (!this.state.activeAccumulator[hostname]) {
this.state.activeAccumulator[hostname] = {
hostname: hostname,
startedAt: now,
lastCheckpointAt: now,
durationMs: 0
};
}
return this.state.activeAccumulator[hostname];
};
DomainActivityTracker.prototype.checkpointUnsafe = function (now) {
if (!this.enabled || !this.activeHostname) return;
var accumulator = this.ensureAccumulator(this.activeHostname, now);
var previous = accumulator.lastCheckpointAt;
var delta = now - previous;
if (!isFiniteNumber(previous) || delta < 0 || delta > this.maxCheckpointGapMs) {
accumulator.startedAt = now;
accumulator.lastCheckpointAt = now;
accumulator.durationMs = 0;
return;
}
if (delta === 0) return;
accumulator.durationMs += delta;
accumulator.lastCheckpointAt = now;
};
DomainActivityTracker.prototype.flush = function (observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
if (!self.enabled) return self.getStateUnsafe();
self.checkpointUnsafe(now);
var entries = [];
Object.keys(self.state.activeAccumulator).forEach(function (hostname) {
var accumulator = self.state.activeAccumulator[hostname];
var durationSeconds = Math.floor(Number(accumulator.durationMs || 0) / 1000);
if (durationSeconds < 1) return;
entries.push({
hostname: accumulator.hostname,
startedAt: toISOString(accumulator.startedAt),
endedAt: toISOString(accumulator.lastCheckpointAt),
durationSeconds: durationSeconds
});
});
if (entries.length) {
self.state.pendingBatches.push({
schemaVersion: 1,
batchId: batchId(now),
createdAt: toISOString(now),
source: 'verstak-browser-extension',
entries: entries
});
self.state.activeAccumulator = {};
if (self.activeHostname) self.ensureAccumulator(self.activeHostname, now);
}
return self.persist().then(function () {
return self.deliverPendingUnsafe(now);
});
});
};
DomainActivityTracker.prototype.retryPending = function () {
var self = this;
return this.enqueue(function () {
if (!self.enabled) return self.getStateUnsafe();
return self.deliverPendingUnsafe(self.now());
});
};
DomainActivityTracker.prototype.deliverPendingUnsafe = function (now) {
var self = this;
var pending = this.state.pendingBatches.slice();
return pending.reduce(function (chain, batch) {
return chain.then(function () {
if (!self.hasPendingBatch(batch.batchId)) return undefined;
var immutableBatch = clone(batch);
return self.sender(immutableBatch).then(function (result) {
if (result && result.batchId && result.batchId !== batch.batchId) {
throw new Error('receiver acknowledged a different activity batch');
}
self.state.pendingBatches = self.state.pendingBatches.filter(function (item) { return item.batchId !== batch.batchId; });
self.state.acknowledgedIds.push({ id: batch.batchId, acknowledgedAt: toISOString(now) });
self.pruneAcknowledged(now);
return self.persist();
});
});
}, Promise.resolve()).then(function () {
return self.getStateUnsafe();
}).catch(function () {
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.hasPendingBatch = function (batchID) {
return this.state.pendingBatches.some(function (batch) { return batch.batchId === batchID; });
};
DomainActivityTracker.prototype.pruneAcknowledged = function (now) {
var cutoff = now - ACK_RETENTION_MS;
var seen = {};
this.state.acknowledgedIds = this.state.acknowledgedIds.filter(function (entry) {
var timestamp = Date.parse(entry && entry.acknowledgedAt);
if (!entry || !entry.id || !Number.isFinite(timestamp) || timestamp < cutoff || seen[entry.id]) return false;
seen[entry.id] = true;
return true;
}).slice(-MAX_ACKNOWLEDGED_IDS);
};
var api = {
ACTIVITY_STATE_KEY: ACTIVITY_STATE_KEY,
MAX_CHECKPOINT_GAP_MS: MAX_CHECKPOINT_GAP_MS,
createMemoryActivityStorage: createMemoryActivityStorage,
browserActivityStorageAdapter: browserActivityStorageAdapter,
DomainActivityTracker: DomainActivityTracker,
isExcludedHostname: isExcludedHostname
};
root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api);
if (typeof module !== 'undefined') module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);