abmeter
Version:
ABMeter browser SDK — feature flags and A/B experiments with server-side pre-evaluated assignments
683 lines (671 loc) • 22.4 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
ApiError: () => ApiError,
VERSION: () => VERSION,
configure: () => configure,
flush: () => flush,
getExposure: () => getExposure,
ready: () => ready,
reset: () => reset,
resolveParameter: () => resolveParameter,
trackEvent: () => trackEvent
});
module.exports = __toCommonJS(src_exports);
// src/constants.ts
var DEFAULT_BASE_URL = "https://abmeter.ai";
var DEFAULT_FLUSH_INTERVAL_MS = 1e3;
var BATCH_SIZE = 20;
var MAX_SUBMIT_ATTEMPTS = 3;
var MAX_RETRY_QUEUE = 1e3;
var DEDUP_WINDOW_MS = 10 * 60 * 1e3;
var DEDUP_MAX_KEYS = 1e3;
var KEEPALIVE_BODY_LIMIT_BYTES = Math.floor(64 * 1024 * 0.8);
var ASSIGNMENTS_STORAGE_PREFIX = "abmeter:assignments:";
var TRACK_ID_STORAGE_KEY = "abmeter:track_id";
var TRACK_ID_COOKIE_NAME = "abmeter_track_id";
var TRACK_ID_COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
var USER_ASSIGNMENTS_PATH = "/api/v1/user-assignments";
var EXPOSURES_PATH = "/api/v1/exposures";
var EVENTS_PATH = "/api/v1/events";
// src/assignments-cache.ts
var AssignmentsCache = class {
constructor({ http, user, logger }) {
this.assignments = {};
this.http = http;
this.user = user;
this.logger = logger;
this.storage = detectStorage();
}
hydrateFromStorage() {
const raw = this.storageRead(this.storageKey());
if (!raw) return false;
try {
const entry = JSON.parse(raw);
if (!entry?.payload?.assignments) return false;
this.assignments = entry.payload.assignments;
this.etag = entry.etag;
return true;
} catch {
return false;
}
}
async refresh() {
const body = this.user.email === void 0 ? { user_id: this.user.userId } : { user_id: this.user.userId, email: this.user.email };
const response = await this.http.fetchAssignments(body, this.etag);
if (response.status === 304) return;
if (response.payload) {
this.assignments = response.payload.assignments;
this.etag = response.etag;
this.persist({ etag: response.etag, payload: response.payload });
}
}
resolveValue(slug) {
return this.assignments[slug]?.value;
}
resolveAssignment(slug) {
return this.assignments[slug];
}
has(slug) {
return slug in this.assignments;
}
persist(entry) {
try {
this.storage?.setItem(this.storageKey(), JSON.stringify(entry));
} catch (error) {
this.logger("assignments cache not persisted", error);
}
}
storageRead(key) {
try {
return this.storage?.getItem(key) ?? null;
} catch {
return null;
}
}
storageKey() {
return `${ASSIGNMENTS_STORAGE_PREFIX}${this.user.userId}`;
}
};
function detectStorage() {
try {
const storage = globalThis.localStorage;
const probe = "abmeter:probe";
storage.setItem(probe, "1");
storage.removeItem(probe);
return storage;
} catch {
return null;
}
}
// src/api-error.ts
var ApiError = class extends Error {
constructor(status, body) {
const parsed = typeof body === "object" && body !== null ? body : {};
super(typeof parsed.error === "string" ? parsed.error : `HTTP ${status}`);
this.name = "ApiError";
this.status = status;
this.details = parsed.details;
}
get retryable() {
return this.status >= 500 || this.status === 408 || this.status === 429;
}
get partialFailure() {
return this.status === 400 && Array.isArray(this.details?.failures) && this.details.failures.length > 0;
}
};
// src/async-submitter.ts
var AsyncSubmitter = class {
constructor(options) {
this.exposures = [];
this.events = [];
this.retryQueue = [];
this.timer = null;
this.flushChain = Promise.resolve();
this.queuedFlush = null;
this.onVisibilityChange = () => {
if (globalThis.document?.visibilityState === "hidden") this.drainOnHide();
};
this.onPageHide = () => {
this.drainOnHide();
};
this.http = options.http;
this.flushIntervalMs = options.flushIntervalMs;
this.batchSize = options.batchSize ?? BATCH_SIZE;
this.logger = options.logger;
this.errorCallback = options.errorCallback;
}
start() {
if (this.timer === null) {
this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
}
globalThis.document?.addEventListener("visibilitychange", this.onVisibilityChange);
globalThis.window?.addEventListener("pagehide", this.onPageHide);
}
/** Detach timer + listeners without draining. reset() is the draining teardown. */
stop() {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
globalThis.document?.removeEventListener("visibilitychange", this.onVisibilityChange);
globalThis.window?.removeEventListener("pagehide", this.onPageHide);
}
queueExposure(data) {
this.exposures.push({ kind: "exposure", data, attempts: 0 });
this.flushIfFull();
}
queueEvent(data) {
this.events.push({ kind: "event", data, attempts: 0 });
this.flushIfFull();
}
pending() {
return this.exposures.length + this.events.length + this.retryQueue.length;
}
/**
* Drain the queues over the network. One retry batch is attempted per flush;
* items failing during this flush land in the retry queue and wait for the
* next one, so a failing server can't spin this loop forever.
*
* Passes are serialized, never concurrent. flush() during a running pass
* returns a promise for the NEXT pass — resolving early while a drain is
* still in flight would break callers (SPA route changes, the canonical
* app's pre-close barrier) that await flush() as proof nothing is queued.
* Callers arriving in the same window share one queued pass.
*/
flush() {
if (this.queuedFlush) return this.queuedFlush;
const pass = this.flushChain.then(() => {
this.queuedFlush = null;
return this.drain();
});
this.queuedFlush = pass;
this.flushChain = pass.catch(() => void 0);
return pass;
}
async drain() {
await this.retryOneBatch();
while (this.exposures.length > 0 || this.events.length > 0) {
if (this.exposures.length > 0) {
await this.submitBatch("exposure", this.exposures.splice(0, this.batchSize));
}
if (this.events.length > 0) {
await this.submitBatch("event", this.events.splice(0, this.batchSize));
}
}
}
/**
* Drain fully and tear down. Terminates even against a failing server: every
* failed batch either drops (validation, events on network error) or re-queues
* with attempts++ and drops at MAX_SUBMIT_ATTEMPTS.
*/
async reset(options = {}) {
this.stop();
if (options.force) {
const dropped = this.pending();
this.exposures = [];
this.events = [];
this.retryQueue = [];
if (dropped > 0) this.logger(`reset(force): dropped ${dropped} queued items`);
return;
}
while (this.pending() > 0) {
await this.flush();
}
}
/**
* Tab-death drain: chunked keepalive fetch (text/plain, CORS-simple) with a
* sendBeacon fallback when the fetch fails. Fire-and-forget by necessity —
* the page may be gone before any response arrives.
*/
drainOnHide() {
const items = [...this.retryQueue, ...this.exposures, ...this.events];
this.retryQueue = [];
this.exposures = [];
this.events = [];
for (const kind of ["exposure", "event"]) {
const rows = items.filter((item) => item.kind === kind).map((item) => item.data);
for (const chunk of chunkForKeepalive(rows, this.batchSize)) {
this.sendChunkOnHide(kind, chunk);
}
}
}
sendChunkOnHide(kind, rows) {
const beaconFallback = () => {
const accepted = kind === "exposure" ? this.http.beaconExposures(rows) : this.http.beaconEvents(rows);
if (!accepted) this.logger(`beacon refused; dropping ${rows.length} ${kind}s`);
};
try {
const post = kind === "exposure" ? this.http.postExposures(rows, { keepalive: true }) : this.http.postEvents(rows, { keepalive: true });
post.catch(beaconFallback);
} catch {
beaconFallback();
}
}
flushIfFull() {
if (this.exposures.length + this.events.length >= this.batchSize) void this.flush();
}
async retryOneBatch() {
if (this.retryQueue.length === 0) return;
const batch = this.retryQueue.splice(0, this.batchSize);
const byKind = /* @__PURE__ */ new Map();
for (const item of batch) {
const bucket = byKind.get(item.kind) ?? [];
bucket.push(item);
byKind.set(item.kind, bucket);
}
for (const [kind, items] of byKind) {
await this.submitBatch(kind, items);
}
}
async submitBatch(kind, items) {
const rows = items.map((item) => item.data);
try {
if (kind === "exposure") {
await this.http.postExposures(rows);
} else {
await this.http.postEvents(rows);
}
} catch (error) {
this.handleSubmitError(kind, items, error);
}
}
// The asymmetric retry contract shared with Ruby/Python: retryable API
// error → re-queue (max attempts); validation/permanent → drop; unknown
// network error → exposures re-queued (they feed the completeness gate),
// events dropped.
handleSubmitError(kind, items, error) {
if (error instanceof ApiError) {
if (error.retryable) {
this.logger(`retryable API error for ${items.length} ${kind}s`, error.message);
this.requeue(items);
} else if (error.partialFailure) {
this.logger(`partial failure, dropping batch of ${items.length} ${kind}s`, error.message);
} else {
this.logger(`permanent API error, dropping ${items.length} ${kind}s`, error.message);
}
} else if (kind === "exposure") {
this.logger(`network error, re-queueing ${items.length} exposures`, error);
this.requeue(items);
} else {
this.logger(`network error, dropping ${items.length} events`, error);
}
this.errorCallback?.(error);
}
requeue(items) {
for (const item of items) {
item.attempts += 1;
if (item.attempts >= MAX_SUBMIT_ATTEMPTS) {
this.logger(`max retries exceeded, dropping ${item.kind}`);
} else if (this.retryQueue.length >= MAX_RETRY_QUEUE) {
this.logger(`retry queue full, dropping ${item.kind}`);
} else {
this.retryQueue.push(item);
}
}
}
};
function chunkForKeepalive(rows, batchSize) {
const chunks = [];
let current = [];
let currentBytes = 2;
for (const row of rows) {
const rowBytes = JSON.stringify(row).length + 1;
const overflow = currentBytes + rowBytes > KEEPALIVE_BODY_LIMIT_BYTES || current.length >= batchSize;
if (overflow && current.length > 0) {
chunks.push(current);
current = [];
currentBytes = 2;
}
current.push(row);
currentBytes += rowBytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
// src/dedup.ts
var ExposureDedup = class {
constructor({
windowMs = DEDUP_WINDOW_MS,
maxKeys = DEDUP_MAX_KEYS,
now = Date.now
} = {}) {
this.entries = /* @__PURE__ */ new Map();
this.windowMs = windowMs;
this.maxKeys = maxKeys;
this.now = now;
}
/** True when the key was already seen inside the window; records the sighting either way. */
seenRecently(key) {
const timestamp = this.now();
const seenAt = this.entries.get(key);
const duplicate = seenAt !== void 0 && timestamp - seenAt < this.windowMs;
this.entries.delete(key);
this.entries.set(key, duplicate ? seenAt : timestamp);
this.evict();
return duplicate;
}
evict() {
while (this.entries.size > this.maxKeys) {
const oldest = this.entries.keys().next().value;
if (oldest === void 0) return;
this.entries.delete(oldest);
}
}
};
function exposureDedupKey(exposure) {
return `${exposure.user_id}|${exposure.exposable_id}|${exposure.audience_id}|${JSON.stringify(exposure.resolved_value)}`;
}
// src/http.ts
var HttpClient = class {
constructor({ baseUrl, apiKey }) {
this.baseUrl = baseUrl;
this.apiKey = apiKey;
}
async fetchAssignments(body, etag) {
const headers = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json"
};
if (etag) headers["If-None-Match"] = etag;
const response = await fetch(`${this.baseUrl}${USER_ASSIGNMENTS_PATH}`, {
method: "POST",
headers,
body: JSON.stringify(body)
});
if (response.status === 304) return { status: 304 };
if (!response.ok) throw new ApiError(response.status, await parseBody(response));
return {
status: 200,
payload: await response.json(),
etag: response.headers.get("ETag") ?? void 0
};
}
async postExposures(rows, options) {
await this.post(EXPOSURES_PATH, rows, options);
}
async postEvents(rows, options) {
await this.post(EVENTS_PATH, rows, options);
}
/**
* Last-resort transport for tab death: sendBeacon cannot set headers, so the
* API key travels as a query token (accepted server-side only on the two
* write endpoints). Returns false when the browser refuses the beacon.
*/
beaconExposures(rows) {
return this.beacon(EXPOSURES_PATH, rows);
}
beaconEvents(rows) {
return this.beacon(EVENTS_PATH, rows);
}
async post(path, rows, options) {
const keepalive = options?.keepalive ?? false;
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
// text/plain on the keepalive path keeps the request CORS-simple (no
// preflight — a preflight can't complete once the page is gone).
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": keepalive ? "text/plain" : "application/json"
},
body: JSON.stringify(rows),
keepalive
});
if (!response.ok) throw new ApiError(response.status, await parseBody(response));
}
beacon(path, rows) {
const sendBeacon = globalThis.navigator?.sendBeacon?.bind(globalThis.navigator);
if (!sendBeacon) return false;
const url = `${this.baseUrl}${path}?api_key=${encodeURIComponent(this.apiKey)}`;
const body = new Blob([JSON.stringify(rows)], { type: "text/plain" });
try {
return sendBeacon(url, body);
} catch {
return false;
}
}
};
async function parseBody(response) {
try {
return await response.json();
} catch {
return void 0;
}
}
// src/user.ts
function ensureUser(input) {
const userId = input?.userId ?? loadOrCreateTrackId();
return input?.email === void 0 ? { userId } : { userId, email: input.email };
}
function loadOrCreateTrackId() {
const existing = readCookie(TRACK_ID_COOKIE_NAME) ?? readStorage(TRACK_ID_STORAGE_KEY);
const trackId = existing ?? generateUuid();
persistTrackId(trackId);
return trackId;
}
function persistTrackId(trackId) {
writeCookie(TRACK_ID_COOKIE_NAME, trackId);
writeStorage(TRACK_ID_STORAGE_KEY, trackId);
}
function generateUuid() {
const cryptoApi = globalThis.crypto;
if (cryptoApi?.randomUUID) return cryptoApi.randomUUID();
const bytes = new Uint8Array(16);
if (cryptoApi?.getRandomValues) {
cryptoApi.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
}
bytes[6] = bytes[6] & 15 | 64;
bytes[8] = bytes[8] & 63 | 128;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function readCookie(name) {
if (typeof document === "undefined") return null;
const match = document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`));
return match ? decodeURIComponent(match.slice(name.length + 1)) || null : null;
}
function writeCookie(name, value) {
if (typeof document === "undefined") return;
try {
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${TRACK_ID_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
} catch {
}
}
function readStorage(key) {
try {
return globalThis.localStorage?.getItem(key) ?? null;
} catch {
return null;
}
}
function writeStorage(key, value) {
try {
globalThis.localStorage?.setItem(key, value);
} catch {
}
}
// src/error-safety.ts
function guard(operation, fallback, handlers2, fn) {
try {
return fn();
} catch (error) {
report(operation, error, handlers2);
return fallback;
}
}
async function guardAsync(operation, fallback, handlers2, fn) {
try {
return await fn();
} catch (error) {
report(operation, error, handlers2);
return fallback;
}
}
function report(operation, error, handlers2) {
if (handlers2.logger) {
handlers2.logger(`${operation} failed`, error);
} else {
console.error(`[abmeter] ${operation} failed`, error);
}
try {
handlers2.errorCallback?.(error);
} catch {
}
}
// src/config.ts
var PUBLISHABLE_KEY_PREFIX = "pk_";
function resolveConfig(input) {
if (!input || typeof input.apiKey !== "string" || input.apiKey.length === 0) {
throw new Error("abmeter.configure: apiKey is required");
}
if (!input.apiKey.startsWith(PUBLISHABLE_KEY_PREFIX)) {
throw new Error(
"abmeter.configure: apiKey must be a publishable key (pk_...) \u2014 mint one on the Lab API Keys page. Secret keys (api-...) must never be shipped in browser code: anyone can read them from your page source and gain full access to your ABMeter account."
);
}
return {
apiKey: input.apiKey,
baseUrl: (input.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
flushIntervalMs: input.flushInterval ?? DEFAULT_FLUSH_INTERVAL_MS,
logger: input.logger ?? defaultLogger,
errorCallback: input.errorCallback
};
}
function defaultLogger(message, payload) {
if (payload === void 0) {
console.warn(`[abmeter] ${message}`);
} else {
console.warn(`[abmeter] ${message}`, payload);
}
}
// src/client.ts
var state = null;
function configure(input) {
const config = resolveConfig(input);
const user = ensureUser(input.user);
const http = new HttpClient({ baseUrl: config.baseUrl, apiKey: config.apiKey });
const cache = new AssignmentsCache({ http, user, logger: config.logger });
const submitter = new AsyncSubmitter({
http,
flushIntervalMs: config.flushIntervalMs,
logger: config.logger,
errorCallback: config.errorCallback
});
if (state) {
const previous = state.submitter;
const pending = previous.pending();
if (pending > 0) state.config.logger(`configure: draining ${pending} queued items from the previous configuration`);
void previous.reset().catch(() => {
});
}
cache.hydrateFromStorage();
submitter.start();
const refreshPromise = cache.refresh().catch((error) => {
config.logger("assignments refresh failed", error);
try {
config.errorCallback?.(error);
} catch {
}
});
state = { config, user, cache, dedup: new ExposureDedup(), submitter, refreshPromise };
}
function ready() {
return state?.refreshPromise ?? Promise.resolve();
}
function resolveParameter(slug) {
return guard("resolveParameter", void 0, handlers(), () => {
const current = requireState();
const assignment = current.cache.resolveAssignment(slug);
if (!assignment) {
current.config.logger(`resolveParameter: unknown parameter '${slug}'`);
return void 0;
}
const exposure = buildExposure(current, slug);
if (exposure && !current.dedup.seenRecently(exposureDedupKey(exposure))) {
current.submitter.queueExposure({ ...exposure });
}
return assignment.value;
});
}
function getExposure(slug) {
return guard("getExposure", null, handlers(), () => buildExposure(requireState(), slug));
}
function trackEvent(eventSlug, customFields) {
guard("trackEvent", void 0, handlers(), () => {
const current = requireState();
current.submitter.queueEvent({
event_slug: eventSlug,
user_id: current.user.userId,
occurred_at: (/* @__PURE__ */ new Date()).toISOString(),
custom_fields: customFields ?? {}
});
});
}
function flush() {
return guardAsync("flush", void 0, handlers(), async () => {
await state?.submitter.flush();
});
}
function reset(options = {}) {
return guardAsync("reset", void 0, handlers(), async () => {
const current = state;
state = null;
await current?.submitter.reset(options);
});
}
function buildExposure(current, slug) {
const assignment = current.cache.resolveAssignment(slug);
if (!assignment?.exposure) return null;
return {
parameter_id: assignment.parameter_id,
space_id: assignment.space_id,
resolved_value: assignment.value,
user_id: current.user.userId,
exposable_type: assignment.exposure.exposable_type,
exposable_id: assignment.exposure.exposable_id,
audience_id: assignment.exposure.audience_id,
resolved_at: (/* @__PURE__ */ new Date()).toISOString()
};
}
function requireState() {
if (!state) throw new Error("abmeter is not configured \u2014 call abmeter.configure(...) first");
return state;
}
function handlers() {
return state ? { logger: state.config.logger, errorCallback: state.config.errorCallback } : {};
}
// src/version.ts
var VERSION = "0.2.2";
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ApiError,
VERSION,
configure,
flush,
getExposure,
ready,
reset,
resolveParameter,
trackEvent
});
//# sourceMappingURL=index.cjs.map