abmeter
Version:
ABMeter browser SDK — feature flags and A/B experiments with server-side pre-evaluated assignments
2 lines (1 loc) • 12.5 kB
JavaScript
"use strict";var abmeter=(()=>{var f=Object.defineProperty;var q=Object.getOwnPropertyDescriptor;var Q=Object.getOwnPropertyNames;var X=Object.prototype.hasOwnProperty;var z=(r,e)=>{for(var t in e)f(r,t,{get:e[t],enumerable:!0})},V=(r,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Q(e))!X.call(r,s)&&s!==t&&f(r,s,{get:()=>e[s],enumerable:!(n=q(e,s))||n.enumerable});return r};var Y=r=>V(f({},"__esModule",{value:!0}),r);var ue={};z(ue,{ApiError:()=>l,VERSION:()=>F,configure:()=>M,flush:()=>D,getExposure:()=>L,ready:()=>O,reset:()=>N,resolveParameter:()=>$,trackEvent:()=>B});var w="https://abmeter.ai";var S=Math.floor(52428.8),I="abmeter:assignments:",u="abmeter:track_id",y="abmeter_track_id",k=365*24*60*60,P="/api/v1/user-assignments",v="/api/v1/exposures",b="/api/v1/events";var p=class{constructor({http:e,user:t,logger:n,storage:s}){this.assignments={};this.http=e,this.user=t,this.logger=n,this.storage=s}async hydrateFromStorage(){let e;try{e=await this.storage.getItem(this.storageKey())}catch{return!1}if(!e)return!1;try{let t=JSON.parse(e);return t?.payload?.assignments?(this.assignments=t.payload.assignments,this.etag=t.etag,!0):!1}catch{return!1}}async refresh(){let e=this.user.email===void 0?{user_id:this.user.userId}:{user_id:this.user.userId,email:this.user.email},t=await this.http.fetchAssignments(e,this.etag);t.status!==304&&t.payload&&(this.assignments=t.payload.assignments,this.etag=t.etag,this.persist({etag:t.etag,payload:t.payload}))}resolveValue(e){return this.assignments[e]?.value}resolveAssignment(e){return this.assignments[e]}has(e){return e in this.assignments}persist(e){let t=n=>this.logger("assignments cache not persisted",n);try{this.storage.setItem(this.storageKey(),JSON.stringify(e)).catch(t)}catch(n){t(n)}}storageKey(){return`${I}${this.user.userId}`}};var l=class extends Error{constructor(e,t){let n=typeof t=="object"&&t!==null?t:{};super(typeof n.error=="string"?n.error:`HTTP ${e}`),this.name="ApiError",this.status=e,this.details=n.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}};var g=class{constructor(e){this.exposures=[];this.events=[];this.retryQueue=[];this.timer=null;this.flushChain=Promise.resolve();this.queuedFlush=null;this.unsubscribeBackground=null;this.http=e.http,this.flushIntervalMs=e.flushIntervalMs,this.batchSize=e.batchSize??20,this.logger=e.logger,this.errorCallback=e.errorCallback,this.lifecycle=e.lifecycle}start(){this.timer===null&&(this.timer=setInterval(()=>{this.flush()},this.flushIntervalMs)),this.unsubscribeBackground??(this.unsubscribeBackground=this.lifecycle.onBackground(()=>this.drainOnHide()))}stop(){this.timer!==null&&(clearInterval(this.timer),this.timer=null),this.unsubscribeBackground?.(),this.unsubscribeBackground=null}queueExposure(e){this.exposures.push({kind:"exposure",data:e,attempts:0}),this.flushIfFull()}queueEvent(e){this.events.push({kind:"event",data:e,attempts:0}),this.flushIfFull()}pending(){return this.exposures.length+this.events.length+this.retryQueue.length}flush(){if(this.queuedFlush)return this.queuedFlush;let e=this.flushChain.then(()=>(this.queuedFlush=null,this.drain()));return this.queuedFlush=e,this.flushChain=e.catch(()=>{}),e}async drain(){for(await this.retryOneBatch();this.exposures.length>0||this.events.length>0;)this.exposures.length>0&&await this.submitBatch("exposure",this.exposures.splice(0,this.batchSize)),this.events.length>0&&await this.submitBatch("event",this.events.splice(0,this.batchSize))}async reset(e={}){if(this.stop(),e.force){let t=this.pending();this.exposures=[],this.events=[],this.retryQueue=[],t>0&&this.logger(`reset(force): dropped ${t} queued items`);return}for(;this.pending()>0;)await this.flush()}drainOnHide(){let e=[...this.retryQueue,...this.exposures,...this.events];this.retryQueue=[],this.exposures=[],this.events=[];for(let t of["exposure","event"]){let n=e.filter(s=>s.kind===t).map(s=>s.data);for(let s of W(n,this.batchSize))this.sendChunkOnHide(t,s)}}sendChunkOnHide(e,t){let n=()=>{(e==="exposure"?this.http.beaconExposures(t):this.http.beaconEvents(t))||this.logger(`beacon refused; dropping ${t.length} ${e}s`)};try{(e==="exposure"?this.http.postExposures(t,{keepalive:!0}):this.http.postEvents(t,{keepalive:!0})).catch(n)}catch{n()}}flushIfFull(){this.exposures.length+this.events.length>=this.batchSize&&this.flush()}async retryOneBatch(){if(this.retryQueue.length===0)return;let e=this.retryQueue.splice(0,this.batchSize),t=new Map;for(let n of e){let s=t.get(n.kind)??[];s.push(n),t.set(n.kind,s)}for(let[n,s]of t)await this.submitBatch(n,s)}async submitBatch(e,t){let n=t.map(s=>s.data);try{e==="exposure"?await this.http.postExposures(n):await this.http.postEvents(n)}catch(s){this.handleSubmitError(e,t,s)}}handleSubmitError(e,t,n){n instanceof l?n.retryable?(this.logger(`retryable API error for ${t.length} ${e}s`,n.message),this.requeue(t)):n.partialFailure?this.logger(`partial failure, dropping batch of ${t.length} ${e}s`,n.message):this.logger(`permanent API error, dropping ${t.length} ${e}s`,n.message):e==="exposure"?(this.logger(`network error, re-queueing ${t.length} exposures`,n),this.requeue(t)):this.logger(`network error, dropping ${t.length} events`,n),this.errorCallback?.(n)}requeue(e){for(let t of e)t.attempts+=1,t.attempts>=3?this.logger(`max retries exceeded, dropping ${t.kind}`):this.retryQueue.length>=1e3?this.logger(`retry queue full, dropping ${t.kind}`):this.retryQueue.push(t)}};function W(r,e){let t=[],n=[],s=2;for(let o of r){let a=JSON.stringify(o).length+1;(s+a>S||n.length>=e)&&n.length>0&&(t.push(n),n=[],s=2),n.push(o),s+=a}return n.length>0&&t.push(n),t}var d=class{constructor({windowMs:e=6e5,maxKeys:t=1e3,now:n=Date.now}={}){this.entries=new Map;this.windowMs=e,this.maxKeys=t,this.now=n}seenRecently(e){let t=this.now(),n=this.entries.get(e),s=n!==void 0&&t-n<this.windowMs;return this.entries.delete(e),this.entries.set(e,s?n:t),this.evict(),s}evict(){for(;this.entries.size>this.maxKeys;){let e=this.entries.keys().next().value;if(e===void 0)return;this.entries.delete(e)}}};function A(r){return`${r.user_id}|${r.exposable_id}|${r.audience_id}|${JSON.stringify(r.resolved_value)}`}var m=class{constructor({baseUrl:e,apiKey:t}){this.baseUrl=e,this.apiKey=t}async fetchAssignments(e,t){let n={Authorization:`Bearer ${this.apiKey}`,"Content-Type":"application/json"};t&&(n["If-None-Match"]=t);let s=await fetch(`${this.baseUrl}${P}`,{method:"POST",headers:n,body:JSON.stringify(e)});if(s.status===304)return{status:304};if(!s.ok)throw new l(s.status,await C(s));return{status:200,payload:await s.json(),etag:s.headers.get("ETag")??void 0}}async postExposures(e,t){await this.post(v,e,t)}async postEvents(e,t){await this.post(b,e,t)}beaconExposures(e){return this.beacon(v,e)}beaconEvents(e){return this.beacon(b,e)}async post(e,t,n){let s=n?.keepalive??!1,o=await fetch(`${this.baseUrl}${e}`,{method:"POST",headers:{Authorization:`Bearer ${this.apiKey}`,"Content-Type":s?"text/plain":"application/json"},body:JSON.stringify(t),keepalive:s});if(!o.ok)throw new l(o.status,await C(o))}beacon(e,t){let n=globalThis.navigator?.sendBeacon?.bind(globalThis.navigator);if(!n)return!1;let s=`${this.baseUrl}${e}?api_key=${encodeURIComponent(this.apiKey)}`,o=new Blob([JSON.stringify(t)],{type:"text/plain"});try{return n(s,o)}catch{return!1}}};async function C(r){try{return await r.json()}catch{return}}async function T(r,e){let t=r?.userId??await Z(e);return r?.email===void 0?{userId:t}:{userId:t,email:r.email}}async function Z(r){let e=null;try{e=await r.getItem(u)}catch{e=null}let t=e??ee();try{await r.setItem(u,t)}catch{}return t}function ee(){let r=globalThis.crypto;if(r?.randomUUID)return r.randomUUID();let e=new Uint8Array(16);if(r?.getRandomValues)r.getRandomValues(e);else for(let n=0;n<e.length;n+=1)e[n]=Math.floor(Math.random()*256);e[6]=e[6]&15|64,e[8]=e[8]&63|128;let t=Array.from(e,n=>n.toString(16).padStart(2,"0")).join("");return`${t.slice(0,8)}-${t.slice(8,12)}-${t.slice(12,16)}-${t.slice(16,20)}-${t.slice(20)}`}function h(r,e,t,n){try{return n()}catch(s){return R(r,s,t),e}}async function E(r,e,t,n){try{return await n()}catch(s){return R(r,s,t),e}}function R(r,e,t){t.logger?t.logger(`${r} failed`,e):console.error(`[abmeter] ${r} failed`,e);try{t.errorCallback?.(e)}catch{}}function U(){return{name:"browser",storage:te(),lifecycle:re()}}function te(){let r=ne();return{async getItem(e){if(e===u){let t=se(y);if(t)return t}try{return r?.getItem(e)??null}catch{return null}},async setItem(e,t){e===u&&oe(y,t),r?.setItem(e,t)},async removeItem(e){try{r?.removeItem(e)}catch{}}}}function re(){return{onBackground(r){let e=()=>{globalThis.document?.visibilityState==="hidden"&&r()},t=()=>r();return globalThis.document?.addEventListener("visibilitychange",e),globalThis.window?.addEventListener("pagehide",t),()=>{globalThis.document?.removeEventListener("visibilitychange",e),globalThis.window?.removeEventListener("pagehide",t)}}}}function ne(){try{let r=globalThis.localStorage,e="abmeter:probe";return r.setItem(e,"1"),r.removeItem(e),r}catch{return null}}function se(r){if(typeof document>"u")return null;let e=document.cookie.split(";").map(t=>t.trim()).find(t=>t.startsWith(`${r}=`));return e&&decodeURIComponent(e.slice(r.length+1))||null}function oe(r,e){if(!(typeof document>"u"))try{document.cookie=`${r}=${encodeURIComponent(e)}; path=/; max-age=${k}; SameSite=Lax`}catch{}}var ae="pk_";function K(r){if(!r||typeof r.apiKey!="string"||r.apiKey.length===0)throw new Error("abmeter.configure: apiKey is required");if(!r.apiKey.startsWith(ae))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:r.apiKey,baseUrl:(r.baseUrl??w).replace(/\/+$/,""),flushIntervalMs:r.flushInterval??1e3,logger:r.logger??le,errorCallback:r.errorCallback,platform:r.platform??U()}}function le(r,e){e===void 0?console.warn(`[abmeter] ${r}`):console.warn(`[abmeter] ${r}`,e)}var i=null;function M(r){let e=K(r),t=new m({baseUrl:e.baseUrl,apiKey:e.apiKey}),n=new g({http:t,flushIntervalMs:e.flushIntervalMs,logger:e.logger,errorCallback:e.errorCallback,lifecycle:e.platform.lifecycle});if(i){let o=i.submitter,a=o.pending();a>0&&i.config.logger(`configure: draining ${a} queued items from the previous configuration`),o.reset().catch(()=>{})}n.start();let s={config:e,user:null,cache:null,dedup:new d,submitter:n,pendingEvents:[],initPromise:Promise.resolve()};s.initPromise=(async()=>{let o=await T(r.user,e.platform.storage);s.user=o;let a=new p({http:t,user:o,logger:e.logger,storage:e.platform.storage});s.cache=a;for(let _ of s.pendingEvents.splice(0))n.queueEvent({..._,user_id:o.userId});await a.hydrateFromStorage(),await a.refresh()})().catch(o=>{e.logger("assignments refresh failed",o);try{e.errorCallback?.(o)}catch{}}),i=s}function O(){return i?.initPromise??Promise.resolve()}function $(r){return h("resolveParameter",void 0,c(),()=>{let e=x(),t=e.cache?.resolveAssignment(r);if(!t){e.config.logger(`resolveParameter: unknown parameter '${r}'`);return}let n=H(e,r);return n&&!e.dedup.seenRecently(A(n))&&e.submitter.queueExposure({...n}),t.value})}function L(r){return h("getExposure",null,c(),()=>H(x(),r))}function B(r,e){h("trackEvent",void 0,c(),()=>{let t=x(),n={event_slug:r,occurred_at:new Date().toISOString(),custom_fields:e??{}};t.user?t.submitter.queueEvent({...n,user_id:t.user.userId}):t.pendingEvents.push(n)})}function D(){return E("flush",void 0,c(),async()=>{await i?.submitter.flush()})}function N(r={}){return E("reset",void 0,c(),async()=>{let e=i;i=null,await e?.submitter.reset(r)})}function H(r,e){if(!r.user)return null;let t=r.cache?.resolveAssignment(e);return t?.exposure?{parameter_id:t.parameter_id,space_id:t.space_id,resolved_value:t.value,user_id:r.user.userId,exposable_type:t.exposure.exposable_type,exposable_id:t.exposure.exposable_id,audience_id:t.exposure.audience_id,resolved_at:new Date().toISOString()}:null}function x(){if(!i)throw new Error("abmeter is not configured \u2014 call abmeter.configure(...) first");return i}function c(){return i?{logger:i.config.logger,errorCallback:i.config.errorCallback}:{}}var F="0.3.0";return Y(ue);})();