UNPKG

abmeter

Version:

ABMeter browser SDK — feature flags and A/B experiments with server-side pre-evaluated assignments

648 lines (638 loc) 21.1 kB
// 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"; export { ApiError, VERSION, configure, flush, getExposure, ready, reset, resolveParameter, trackEvent }; //# sourceMappingURL=index.mjs.map