@schematichq/schematic-js 1.2.6 → 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/schematic.browser.js +2 -2
- package/dist/schematic.cjs.js +318 -7
- package/dist/schematic.d.ts +54 -0
- package/dist/schematic.esm.js +318 -7
- package/package.json +16 -19
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023-2025 Schematic, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
"use strict";(()=>{var re=Object.create;var
|
|
2
|
-
`)===0?h.substr(1,h.length):h}).forEach(function(h){var p=h.split(":"),d=p.shift().trim();if(d){var U=p.join(":").trim();try{i.append(d,U)}catch(A){console.warn("Response "+A.message)}}}),i}$.call(S.prototype);function E(n,i){if(!(this instanceof E))throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.');if(i||(i={}),this.type="default",this.status=i.status===void 0?200:i.status,this.status<200||this.status>599)throw new RangeError("Failed to construct 'Response': The status provided (0) is outside the range [200, 599].");this.ok=this.status>=200&&this.status<300,this.statusText=i.statusText===void 0?"":""+i.statusText,this.headers=new g(i.headers),this.url=i.url||"",this._initBody(n)}$.call(E.prototype),E.prototype.clone=function(){return new E(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new g(this.headers),url:this.url})},E.error=function(){var n=new E(null,{status:200,statusText:""});return n.ok=!1,n.status=0,n.type="error",n};var ne=[301,302,303,307,308];E.redirect=function(n,i){if(ne.indexOf(i)===-1)throw new RangeError("Invalid status code");return new E(null,{status:i,headers:{location:n}})},t.DOMException=s.DOMException;try{new t.DOMException}catch{t.DOMException=function(i,l){this.message=i,this.name=l;var h=Error(i);this.stack=h.stack},t.DOMException.prototype=Object.create(Error.prototype),t.DOMException.prototype.constructor=t.DOMException}function I(n,i){return new Promise(function(l,h){var p=new S(n,i);if(p.signal&&p.signal.aborted)return h(new t.DOMException("Aborted","AbortError"));var d=new XMLHttpRequest;function U(){d.abort()}d.onload=function(){var b={statusText:d.statusText,headers:te(d.getAllResponseHeaders()||"")};p.url.indexOf("file://")===0&&(d.status<200||d.status>599)?b.status=200:b.status=d.status,b.url="responseURL"in d?d.responseURL:b.headers.get("X-Request-URL");var R="response"in d?d.response:d.responseText;setTimeout(function(){l(new E(R,b))},0)},d.onerror=function(){setTimeout(function(){h(new TypeError("Network request failed"))},0)},d.ontimeout=function(){setTimeout(function(){h(new TypeError("Network request timed out"))},0)},d.onabort=function(){setTimeout(function(){h(new t.DOMException("Aborted","AbortError"))},0)};function A(b){try{return b===""&&s.location.href?s.location.href:b}catch{return b}}if(d.open(p.method,A(p.url),!0),p.credentials==="include"?d.withCredentials=!0:p.credentials==="omit"&&(d.withCredentials=!1),"responseType"in d&&(a.blob?d.responseType="blob":a.arrayBuffer&&(d.responseType="arraybuffer")),i&&typeof i.headers=="object"&&!(i.headers instanceof g||s.Headers&&i.headers instanceof s.Headers)){var H=[];Object.getOwnPropertyNames(i.headers).forEach(function(b){H.push(f(b)),d.setRequestHeader(b,y(i.headers[b]))}),p.headers.forEach(function(b,R){H.indexOf(R)===-1&&d.setRequestHeader(R,b)})}else p.headers.forEach(function(b,R){d.setRequestHeader(R,b)});p.signal&&(p.signal.addEventListener("abort",U),d.onreadystatechange=function(){d.readyState===4&&p.signal.removeEventListener("abort",U)}),d.send(typeof p._bodyInit>"u"?null:p._bodyInit)})}return I.polyfill=!0,s.fetch||(s.fetch=I,s.Headers=g,s.Request=S,s.Response=E),t.Headers=g,t.Request=S,t.Response=E,t.fetch=I,t})({})})(typeof self<"u"?self:Q)});var v=[];for(let r=0;r<256;++r)v.push((r+256).toString(16).slice(1));function K(r,e=0){return(v[r[e+0]]+v[r[e+1]]+v[r[e+2]]+v[r[e+3]]+"-"+v[r[e+4]]+v[r[e+5]]+"-"+v[r[e+6]]+v[r[e+7]]+"-"+v[r[e+8]]+v[r[e+9]]+"-"+v[r[e+10]]+v[r[e+11]]+v[r[e+12]]+v[r[e+13]]+v[r[e+14]]+v[r[e+15]]).toLowerCase()}var P,de=new Uint8Array(16);function B(){if(!P){if(typeof crypto>"u"||!crypto.getRandomValues)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");P=crypto.getRandomValues.bind(crypto)}return P(de)}var fe=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),L={randomUUID:fe};function he(r,e,t){r=r||{};let s=r.random??r.rng?.()??B();if(s.length<16)throw new Error("Random bytes length must be >= 16");if(s[6]=s[6]&15|64,s[8]=s[8]&63|128,e){if(t=t||0,t<0||t+16>e.length)throw new RangeError(`UUID byte range ${t}:${t+15} is out of buffer bounds`);for(let a=0;a<16;++a)e[t+a]=s[a];return e}return K(s)}function ge(r,e,t){return L.randomUUID&&!e&&!r?L.randomUUID():he(r,e,t)}var _=ge;var st=ce(z());function w(r){return pe(r,!1)}function pe(r,e){return r==null?r:{companyId:r.company_id==null?void 0:r.company_id,error:r.error==null?void 0:r.error,featureAllocation:r.feature_allocation==null?void 0:r.feature_allocation,featureUsage:r.feature_usage==null?void 0:r.feature_usage,featureUsageEvent:r.feature_usage_event==null?void 0:r.feature_usage_event,featureUsagePeriod:r.feature_usage_period==null?void 0:r.feature_usage_period,featureUsageResetAt:r.feature_usage_reset_at==null?void 0:new Date(r.feature_usage_reset_at),flag:r.flag,flagId:r.flag_id==null?void 0:r.flag_id,reason:r.reason,ruleId:r.rule_id==null?void 0:r.rule_id,ruleType:r.rule_type==null?void 0:r.rule_type,userId:r.user_id==null?void 0:r.user_id,value:r.value}}function N(r){return ye(r,!1)}function ye(r,e=!1){return r==null?r:{company_id:r.companyId,error:r.error,flag_id:r.flagId,flag_key:r.flagKey,reason:r.reason,req_company:r.reqCompany,req_user:r.reqUser,rule_id:r.ruleId,user_id:r.userId,value:r.value}}function T(r){return be(r,!1)}function be(r,e){return r==null?r:{data:w(r.data),params:r.params}}function X(r){return ve(r,!1)}function ve(r,e){return r==null?r:{flags:r.flags.map(w)}}function J(r){return ke(r,!1)}function ke(r,e){return r==null?r:{data:X(r.data),params:r.params}}var O=r=>{let{companyId:e,error:t,featureAllocation:s,featureUsage:a,featureUsageEvent:c,featureUsagePeriod:o,featureUsageResetAt:u,flag:f,flagId:y,reason:F,ruleId:g,ruleType:k,userId:m,value:x}=w(r);return{featureUsageExceeded:!x&&(k=="company_override_usage_exceeded"||k=="plan_entitlement_usage_exceeded"),companyId:e??void 0,error:t??void 0,featureAllocation:s??void 0,featureUsage:a??void 0,featureUsageEvent:c===null?void 0:c,featureUsagePeriod:o??void 0,featureUsageResetAt:u??void 0,flag:f,flagId:y??void 0,reason:F,ruleId:g??void 0,ruleType:k??void 0,userId:m??void 0,value:x}};function C(r){let e=Object.keys(r).reduce((t,s)=>{let c=Object.keys(r[s]||{}).sort().reduce((o,u)=>(o[u]=r[s][u],o),{});return t[s]=c,t},{});return JSON.stringify(e)}var M="1.2.6";var G="schematicId";var D=class{additionalHeaders={};apiKey;apiUrl="https://api.schematichq.com";conn=null;context={};debugEnabled=!1;offlineEnabled=!1;eventQueue;contextDependentEventQueue;eventUrl="https://c.schematichq.com";flagCheckListeners={};flagValueListeners={};isPending=!0;isPendingListeners=new Set;storage;useWebSocket=!1;checks={};featureUsageEventMap={};webSocketUrl="wss://api.schematichq.com";constructor(e,t){if(this.apiKey=e,this.eventQueue=[],this.contextDependentEventQueue=[],this.useWebSocket=t?.useWebSocket??!1,this.debugEnabled=t?.debug??!1,this.offlineEnabled=t?.offline??!1,typeof window<"u"&&typeof window.location<"u"){let s=new URLSearchParams(window.location.search),a=s.get("schematic_debug");a!==null&&(a===""||a==="true"||a==="1")&&(this.debugEnabled=!0);let c=s.get("schematic_offline");c!==null&&(c===""||c==="true"||c==="1")&&(this.offlineEnabled=!0,this.debugEnabled=!0)}this.offlineEnabled&&t?.debug!==!1&&(this.debugEnabled=!0),this.offlineEnabled&&this.setIsPending(!1),this.additionalHeaders={"X-Schematic-Client-Version":`schematic-js@${M}`,...t?.additionalHeaders??{}},t?.storage?this.storage=t.storage:typeof localStorage<"u"&&(this.storage=localStorage),t?.apiUrl!==void 0&&(this.apiUrl=t.apiUrl),t?.eventUrl!==void 0&&(this.eventUrl=t.eventUrl),t?.webSocketUrl!==void 0&&(this.webSocketUrl=t.webSocketUrl),typeof window<"u"&&window?.addEventListener&&window.addEventListener("beforeunload",()=>{this.flushEventQueue(),this.flushContextDependentEventQueue()}),this.offlineEnabled?this.debug("Initialized with offline mode enabled - no network requests will be made"):this.debugEnabled&&this.debug("Initialized with debug mode enabled")}async checkFlag(e){let{fallback:t=!1,key:s}=e,a=e.context||this.context,c=C(a);if(this.debug(`checkFlag: ${s}`,{context:a,fallback:t}),this.isOffline())return this.debug(`checkFlag offline result: ${s}`,{value:t,offlineMode:!0}),t;if(!this.useWebSocket){let o=`${this.apiUrl}/flags/${s}/check`;return fetch(o,{method:"POST",headers:{...this.additionalHeaders??{},"Content-Type":"application/json;charset=UTF-8","X-Schematic-Api-Key":this.apiKey},body:JSON.stringify(a)}).then(u=>{if(!u.ok)throw new Error("Network response was not ok");return u.json()}).then(u=>{let f=T(u);this.debug(`checkFlag result: ${s}`,f);let y=O(f.data);return typeof y.featureUsageEvent=="string"&&this.updateFeatureUsageEventMap(y),this.submitFlagCheckEvent(s,y,a),y.value}).catch(u=>{console.error("There was a problem with the fetch operation:",u);let f={flag:s,value:t,reason:"API request failed",error:u instanceof Error?u.message:String(u)};return this.submitFlagCheckEvent(s,f,a),t})}try{let o=this.checks[c];if(this.conn!==null&&typeof o<"u"&&typeof o[s]<"u")return this.debug(`checkFlag cached result: ${s}`,o[s]),o[s].value;if(this.isOffline())return t;try{await this.setContext(a)}catch(F){return console.error("WebSocket connection failed, falling back to REST:",F),this.fallbackToRest(s,a,t)}let f=(this.checks[c]??{})[s],y=f?.value??t;return this.debug(`checkFlag WebSocket result: ${s}`,typeof f<"u"?f:{value:t,fallbackUsed:!0}),typeof f<"u"&&this.submitFlagCheckEvent(s,f,a),y}catch(o){console.error("Unexpected error in checkFlag:",o);let u={flag:s,value:t,reason:"Unexpected error in flag check",error:o instanceof Error?o.message:String(o)};return this.submitFlagCheckEvent(s,u,a),t}}debug(e,...t){this.debugEnabled&&console.log(`[Schematic] ${e}`,...t)}isOffline(){return this.offlineEnabled}submitFlagCheckEvent(e,t,s){let a={flagKey:e,value:t.value,reason:t.reason,flagId:t.flagId,ruleId:t.ruleId,companyId:t.companyId,userId:t.userId,error:t.error,reqCompany:s.company,reqUser:s.user};return this.debug("submitting flag check event:",a),this.handleEvent("flag_check",N(a))}async fallbackToRest(e,t,s){if(this.isOffline())return this.debug(`fallbackToRest offline result: ${e}`,{value:s,offlineMode:!0}),s;try{let a=`${this.apiUrl}/flags/${e}/check`,c=await fetch(a,{method:"POST",headers:{...this.additionalHeaders??{},"Content-Type":"application/json;charset=UTF-8","X-Schematic-Api-Key":this.apiKey},body:JSON.stringify(t)});if(!c.ok)throw new Error("Network response was not ok");let o=await c.json(),u=T(o);this.debug(`fallbackToRest result: ${e}`,u);let f=O(u.data);return typeof f.featureUsageEvent=="string"&&this.updateFeatureUsageEventMap(f),this.submitFlagCheckEvent(e,f,t),f.value}catch(a){console.error("REST API call failed, using fallback value:",a);let c={flag:e,value:s,reason:"API request failed (fallback)",error:a instanceof Error?a.message:String(a)};return this.submitFlagCheckEvent(e,c,t),s}}checkFlags=async e=>{if(e=e||this.context,this.debug("checkFlags",{context:e}),this.isOffline())return this.debug("checkFlags offline result: returning empty object"),{};let t=`${this.apiUrl}/flags/check`,s=JSON.stringify(e);return fetch(t,{method:"POST",headers:{...this.additionalHeaders??{},"Content-Type":"application/json;charset=UTF-8","X-Schematic-Api-Key":this.apiKey},body:s}).then(a=>{if(!a.ok)throw new Error("Network response was not ok");return a.json()}).then(a=>{let c=J(a);return this.debug("checkFlags result:",c),(c?.data?.flags??[]).reduce((o,u)=>(o[u.flag]=u.value,o),{})}).catch(a=>(console.error("There was a problem with the fetch operation:",a),{}))};identify=e=>{this.debug("identify:",e);try{this.setContext({company:e.company?.keys,user:e.keys})}catch(t){console.error("Error setting context:",t)}return this.handleEvent("identify",e)};setContext=async e=>{if(this.isOffline()||!this.useWebSocket)return this.context=e,this.flushContextDependentEventQueue(),this.setIsPending(!1),Promise.resolve();try{this.setIsPending(!0),this.conn||(this.conn=this.wsConnect());let t=await this.conn;await this.wsSendMessage(t,e)}catch(t){throw console.error("Failed to establish WebSocket connection:",t),t}};track=e=>{let{company:t,user:s,event:a,traits:c,quantity:o=1}=e;if(!this.hasContext(t,s)){this.debug(`track: queuing event "${a}" until context is available`);let f={api_key:this.apiKey,body:{company:t,event:a,traits:c??{},user:s,quantity:o},sent_at:new Date().toISOString(),tracker_event_id:_(),tracker_user_id:this.getAnonymousId(),type:"track"};return this.contextDependentEventQueue.push(f),Promise.resolve()}let u={company:t??this.context.company,event:a,traits:c??{},user:s??this.context.user,quantity:o};return this.debug("track:",u),a in this.featureUsageEventMap&&this.optimisticallyUpdateFeatureUsage(a,o),this.handleEvent("track",u)};optimisticallyUpdateFeatureUsage=(e,t=1)=>{let s=this.featureUsageEventMap[e];s!=null&&(this.debug(`Optimistically updating feature usage for event: ${e}`,{quantity:t}),Object.entries(s).forEach(([a,c])=>{if(c===void 0)return;let o={...c};if(typeof o.featureUsage=="number"){if(o.featureUsage+=t,typeof o.featureAllocation=="number"){let f=o.featureUsageExceeded===!0,y=o.featureUsage>=o.featureAllocation;y!==f&&(o.featureUsageExceeded=y,y&&(o.value=!1),this.debug(`Usage limit status changed for flag: ${a}`,{was:f?"exceeded":"within limits",now:y?"exceeded":"within limits",featureUsage:o.featureUsage,featureAllocation:o.featureAllocation,value:o.value}))}this.featureUsageEventMap[e]!==void 0&&(this.featureUsageEventMap[e][a]=o);let u=C(this.context);this.checks[u]!==void 0&&this.checks[u]!==null&&(this.checks[u][a]=o),this.notifyFlagCheckListeners(a,o),this.notifyFlagValueListeners(a,o.value)}}))};hasContext=(e,t)=>{let s=e!=null&&Object.keys(e).length>0||t!=null&&Object.keys(t).length>0,a=this.context.company!==void 0&&this.context.company!==null&&Object.keys(this.context.company).length>0||this.context.user!==void 0&&this.context.user!==null&&Object.keys(this.context.user).length>0;return s||a};flushContextDependentEventQueue=()=>{for(this.debug(`flushing ${this.contextDependentEventQueue.length} context-dependent events`);this.contextDependentEventQueue.length>0;){let e=this.contextDependentEventQueue.shift();if(e)if(e.type==="track"&&typeof e.body=="object"&&e.body!==null){let t=e.body,s={...t,company:t.company??this.context.company,user:t.user??this.context.user},a={...e,body:s,sent_at:new Date().toISOString()};this.sendEvent(a)}else this.sendEvent(e)}};flushEventQueue=()=>{for(;this.eventQueue.length>0;){let e=this.eventQueue.shift();e&&this.sendEvent(e)}};getAnonymousId=()=>{if(!this.storage)return _();let e=this.storage.getItem(G);if(typeof e<"u")return e;let t=_();return this.storage.setItem(G,t),t};handleEvent=(e,t)=>{let s={api_key:this.apiKey,body:t,sent_at:new Date().toISOString(),tracker_event_id:_(),tracker_user_id:this.getAnonymousId(),type:e};return typeof document<"u"&&document?.hidden?this.storeEvent(s):this.sendEvent(s)};sendEvent=async e=>{let t=`${this.eventUrl}/e`,s=JSON.stringify(e);if(this.debug("sending event:",{url:t,event:e}),this.isOffline())return this.debug("event not sent (offline mode):",{event:e}),Promise.resolve();try{let a=await fetch(t,{method:"POST",headers:{...this.additionalHeaders??{},"Content-Type":"application/json;charset=UTF-8"},body:s});this.debug("event sent:",{status:a.status,statusText:a.statusText})}catch(a){console.error("Error sending Schematic event: ",a)}return Promise.resolve()};storeEvent=e=>(this.eventQueue.push(e),Promise.resolve());cleanup=async()=>{if(this.isOffline())return this.debug("cleanup: skipped (offline mode)"),Promise.resolve();if(this.conn)try{(await this.conn).close()}catch(e){console.error("Error during cleanup:",e)}finally{this.conn=null}};wsConnect=()=>this.isOffline()?(this.debug("wsConnect: skipped (offline mode)"),Promise.reject(new Error("WebSocket connection skipped in offline mode"))):new Promise((e,t)=>{let s=`${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;this.debug("connecting to WebSocket:",s);let a=new WebSocket(s);a.onopen=()=>{this.debug("WebSocket connection opened"),e(a)},a.onerror=c=>{this.debug("WebSocket connection error:",c),t(c)},a.onclose=()=>{this.debug("WebSocket connection closed"),this.conn=null}});wsSendMessage=(e,t)=>this.isOffline()?(this.debug("wsSendMessage: skipped (offline mode)"),this.setIsPending(!1),Promise.resolve()):new Promise((s,a)=>{if(C(t)==C(this.context))return this.debug("WebSocket context unchanged, skipping update"),s(this.setIsPending(!1));this.debug("WebSocket context updated:",t),this.context=t;let c=()=>{let o=!1,u=F=>{let g=JSON.parse(F.data);this.debug("WebSocket message received:",g),C(t)in this.checks||(this.checks[C(t)]={}),(g.flags??[]).forEach(k=>{let m=O(k),x=C(t);this.checks[x]===void 0&&(this.checks[x]={}),this.checks[x][m.flag]=m,this.debug("WebSocket flag update:",{flag:m.flag,value:m.value,flagCheck:m}),typeof m.featureUsageEvent=="string"&&this.updateFeatureUsageEventMap(m),(this.flagCheckListeners[k.flag]?.size>0||this.flagValueListeners[k.flag]?.size>0)&&this.submitFlagCheckEvent(m.flag,m,t),this.notifyFlagCheckListeners(k.flag,m),this.notifyFlagValueListeners(k.flag,m.value)}),this.flushContextDependentEventQueue(),this.setIsPending(!1),o||(o=!0,s())};e.addEventListener("message",u);let f=this.additionalHeaders["X-Schematic-Client-Version"]??`schematic-js@${M}`,y={apiKey:this.apiKey,clientVersion:f,data:t};this.debug("WebSocket sending message:",y),e.send(JSON.stringify(y))};e.readyState===WebSocket.OPEN?(this.debug("WebSocket already open, sending message"),c()):e.readyState===WebSocket.CONNECTING?(this.debug("WebSocket connecting, waiting for open to send message"),e.addEventListener("open",c)):(this.debug("WebSocket is closed, cannot send message"),a("WebSocket is not open or connecting"))});getIsPending=()=>this.isPending;addIsPendingListener=e=>(this.isPendingListeners.add(e),()=>{this.isPendingListeners.delete(e)});setIsPending=e=>{this.isPending=e,this.isPendingListeners.forEach(t=>Ee(t,e))};getFlagCheck=e=>{let t=C(this.context);return(this.checks[t]??{})[e]};getFlagValue=e=>this.getFlagCheck(e)?.value;addFlagValueListener=(e,t)=>(e in this.flagValueListeners||(this.flagValueListeners[e]=new Set),this.flagValueListeners[e].add(t),()=>{this.flagValueListeners[e].delete(t)});addFlagCheckListener=(e,t)=>(e in this.flagCheckListeners||(this.flagCheckListeners[e]=new Set),this.flagCheckListeners[e].add(t),()=>{this.flagCheckListeners[e].delete(t)});notifyFlagCheckListeners=(e,t)=>{let s=this.flagCheckListeners?.[e]??[];s.size>0&&this.debug(`Notifying ${s.size} flag check listeners for ${e}`,t),typeof t.featureUsageEvent=="string"&&this.updateFeatureUsageEventMap(t),s.forEach(a=>Fe(a,t))};updateFeatureUsageEventMap=e=>{if(typeof e.featureUsageEvent!="string")return;let t=e.featureUsageEvent;(this.featureUsageEventMap[t]===void 0||this.featureUsageEventMap[t]===null)&&(this.featureUsageEventMap[t]={}),this.featureUsageEventMap[t]!==void 0&&(this.featureUsageEventMap[t][e.flag]=e),this.debug(`Updated featureUsageEventMap for event: ${t}, flag: ${e.flag}`,e)};notifyFlagValueListeners=(e,t)=>{let s=this.flagValueListeners?.[e]??[];s.size>0&&this.debug(`Notifying ${s.size} flag value listeners for ${e}`,{value:t}),s.forEach(a=>Ce(a,t))}},Ee=(r,e)=>{r.length>0?r(e):r()},Fe=(r,e)=>{r.length>0?r(e):r()},Ce=(r,e)=>{r.length>0?r(e):r()};window.Schematic=D;})();
|
|
1
|
+
"use strict";(()=>{var re=Object.create;var Q=Object.defineProperty;var se=Object.getOwnPropertyDescriptor;var ie=Object.getOwnPropertyNames;var ae=Object.getPrototypeOf,oe=Object.prototype.hasOwnProperty;var ce=(r,t)=>()=>(t||r((t={exports:{}}).exports,t),t.exports);var le=(r,t,e,i)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of ie(t))!oe.call(r,s)&&s!==e&&Q(r,s,{get:()=>t[s],enumerable:!(i=se(t,s))||i.enumerable});return r};var ue=(r,t,e)=>(e=r!=null?re(ae(r)):{},le(t||!r||!r.__esModule?Q(e,"default",{value:r,enumerable:!0}):e,r));var K=ce(z=>{(function(r){var t=(function(e){var i=typeof globalThis<"u"&&globalThis||typeof r<"u"&&r||typeof global<"u"&&global||{},s={searchParams:"URLSearchParams"in i,iterable:"Symbol"in i&&"iterator"in Symbol,blob:"FileReader"in i&&"Blob"in i&&(function(){try{return new Blob,!0}catch{return!1}})(),formData:"FormData"in i,arrayBuffer:"ArrayBuffer"in i};function c(n){return n&&DataView.prototype.isPrototypeOf(n)}if(s.arrayBuffer)var o=["[object Int8Array]","[object Uint8Array]","[object Uint8ClampedArray]","[object Int16Array]","[object Uint16Array]","[object Int32Array]","[object Uint32Array]","[object Float32Array]","[object Float64Array]"],l=ArrayBuffer.isView||function(n){return n&&o.indexOf(Object.prototype.toString.call(n))>-1};function d(n){if(typeof n!="string"&&(n=String(n)),/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(n)||n==="")throw new TypeError('Invalid character in header field name: "'+n+'"');return n.toLowerCase()}function p(n){return typeof n!="string"&&(n=String(n)),n}function E(n){var a={next:function(){var u=n.shift();return{done:u===void 0,value:u}}};return s.iterable&&(a[Symbol.iterator]=function(){return a}),a}function h(n){this.map={},n instanceof h?n.forEach(function(a,u){this.append(u,a)},this):Array.isArray(n)?n.forEach(function(a){if(a.length!=2)throw new TypeError("Headers constructor: expected name/value pair to be length 2, found"+a.length);this.append(a[0],a[1])},this):n&&Object.getOwnPropertyNames(n).forEach(function(a){this.append(a,n[a])},this)}h.prototype.append=function(n,a){n=d(n),a=p(a);var u=this.map[n];this.map[n]=u?u+", "+a:a},h.prototype.delete=function(n){delete this.map[d(n)]},h.prototype.get=function(n){return n=d(n),this.has(n)?this.map[n]:null},h.prototype.has=function(n){return this.map.hasOwnProperty(d(n))},h.prototype.set=function(n,a){this.map[d(n)]=p(a)},h.prototype.forEach=function(n,a){for(var u in this.map)this.map.hasOwnProperty(u)&&n.call(a,this.map[u],u,this)},h.prototype.keys=function(){var n=[];return this.forEach(function(a,u){n.push(u)}),E(n)},h.prototype.values=function(){var n=[];return this.forEach(function(a){n.push(a)}),E(n)},h.prototype.entries=function(){var n=[];return this.forEach(function(a,u){n.push([u,a])}),E(n)},s.iterable&&(h.prototype[Symbol.iterator]=h.prototype.entries);function b(n){if(!n._noBody){if(n.bodyUsed)return Promise.reject(new TypeError("Already read"));n.bodyUsed=!0}}function m(n){return new Promise(function(a,u){n.onload=function(){a(n.result)},n.onerror=function(){u(n.error)}})}function S(n){var a=new FileReader,u=m(a);return a.readAsArrayBuffer(n),u}function J(n){var a=new FileReader,u=m(a),g=/charset=([A-Za-z0-9_-]+)/.exec(n.type),y=g?g[1]:"utf-8";return a.readAsText(n,y),u}function Y(n){for(var a=new Uint8Array(n),u=new Array(a.length),g=0;g<a.length;g++)u[g]=String.fromCharCode(a[g]);return u.join("")}function q(n){if(n.slice)return n.slice(0);var a=new Uint8Array(n.byteLength);return a.set(new Uint8Array(n)),a.buffer}function V(){return this.bodyUsed=!1,this._initBody=function(n){this.bodyUsed=this.bodyUsed,this._bodyInit=n,n?typeof n=="string"?this._bodyText=n:s.blob&&Blob.prototype.isPrototypeOf(n)?this._bodyBlob=n:s.formData&&FormData.prototype.isPrototypeOf(n)?this._bodyFormData=n:s.searchParams&&URLSearchParams.prototype.isPrototypeOf(n)?this._bodyText=n.toString():s.arrayBuffer&&s.blob&&c(n)?(this._bodyArrayBuffer=q(n.buffer),this._bodyInit=new Blob([this._bodyArrayBuffer])):s.arrayBuffer&&(ArrayBuffer.prototype.isPrototypeOf(n)||l(n))?this._bodyArrayBuffer=q(n):this._bodyText=n=Object.prototype.toString.call(n):(this._noBody=!0,this._bodyText=""),this.headers.get("content-type")||(typeof n=="string"?this.headers.set("content-type","text/plain;charset=UTF-8"):this._bodyBlob&&this._bodyBlob.type?this.headers.set("content-type",this._bodyBlob.type):s.searchParams&&URLSearchParams.prototype.isPrototypeOf(n)&&this.headers.set("content-type","application/x-www-form-urlencoded;charset=UTF-8"))},s.blob&&(this.blob=function(){var n=b(this);if(n)return n;if(this._bodyBlob)return Promise.resolve(this._bodyBlob);if(this._bodyArrayBuffer)return Promise.resolve(new Blob([this._bodyArrayBuffer]));if(this._bodyFormData)throw new Error("could not read FormData body as blob");return Promise.resolve(new Blob([this._bodyText]))}),this.arrayBuffer=function(){if(this._bodyArrayBuffer){var n=b(this);return n||(ArrayBuffer.isView(this._bodyArrayBuffer)?Promise.resolve(this._bodyArrayBuffer.buffer.slice(this._bodyArrayBuffer.byteOffset,this._bodyArrayBuffer.byteOffset+this._bodyArrayBuffer.byteLength)):Promise.resolve(this._bodyArrayBuffer))}else{if(s.blob)return this.blob().then(S);throw new Error("could not read as ArrayBuffer")}},this.text=function(){var n=b(this);if(n)return n;if(this._bodyBlob)return J(this._bodyBlob);if(this._bodyArrayBuffer)return Promise.resolve(Y(this._bodyArrayBuffer));if(this._bodyFormData)throw new Error("could not read FormData body as text");return Promise.resolve(this._bodyText)},s.formData&&(this.formData=function(){return this.text().then(ee)}),this.json=function(){return this.text().then(JSON.parse)},this}var Z=["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"];function j(n){var a=n.toUpperCase();return Z.indexOf(a)>-1?a:n}function F(n,a){if(!(this instanceof F))throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.');a=a||{};var u=a.body;if(n instanceof F){if(n.bodyUsed)throw new TypeError("Already read");this.url=n.url,this.credentials=n.credentials,a.headers||(this.headers=new h(n.headers)),this.method=n.method,this.mode=n.mode,this.signal=n.signal,!u&&n._bodyInit!=null&&(u=n._bodyInit,n.bodyUsed=!0)}else this.url=String(n);if(this.credentials=a.credentials||this.credentials||"same-origin",(a.headers||!this.headers)&&(this.headers=new h(a.headers)),this.method=j(a.method||this.method||"GET"),this.mode=a.mode||this.mode||null,this.signal=a.signal||this.signal||(function(){if("AbortController"in i){var f=new AbortController;return f.signal}})(),this.referrer=null,(this.method==="GET"||this.method==="HEAD")&&u)throw new TypeError("Body not allowed for GET or HEAD requests");if(this._initBody(u),(this.method==="GET"||this.method==="HEAD")&&(a.cache==="no-store"||a.cache==="no-cache")){var g=/([?&])_=[^&]*/;if(g.test(this.url))this.url=this.url.replace(g,"$1_="+new Date().getTime());else{var y=/\?/;this.url+=(y.test(this.url)?"&":"?")+"_="+new Date().getTime()}}}F.prototype.clone=function(){return new F(this,{body:this._bodyInit})};function ee(n){var a=new FormData;return n.trim().split("&").forEach(function(u){if(u){var g=u.split("="),y=g.shift().replace(/\+/g," "),f=g.join("=").replace(/\+/g," ");a.append(decodeURIComponent(y),decodeURIComponent(f))}}),a}function te(n){var a=new h,u=n.replace(/\r?\n[\t ]+/g," ");return u.split("\r").map(function(g){return g.indexOf(`
|
|
2
|
+
`)===0?g.substr(1,g.length):g}).forEach(function(g){var y=g.split(":"),f=y.shift().trim();if(f){var D=y.join(":").trim();try{a.append(f,D)}catch(P){console.warn("Response "+P.message)}}}),a}V.call(F.prototype);function w(n,a){if(!(this instanceof w))throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.');if(a||(a={}),this.type="default",this.status=a.status===void 0?200:a.status,this.status<200||this.status>599)throw new RangeError("Failed to construct 'Response': The status provided (0) is outside the range [200, 599].");this.ok=this.status>=200&&this.status<300,this.statusText=a.statusText===void 0?"":""+a.statusText,this.headers=new h(a.headers),this.url=a.url||"",this._initBody(n)}V.call(w.prototype),w.prototype.clone=function(){return new w(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new h(this.headers),url:this.url})},w.error=function(){var n=new w(null,{status:200,statusText:""});return n.ok=!1,n.status=0,n.type="error",n};var ne=[301,302,303,307,308];w.redirect=function(n,a){if(ne.indexOf(a)===-1)throw new RangeError("Invalid status code");return new w(null,{status:a,headers:{location:n}})},e.DOMException=i.DOMException;try{new e.DOMException}catch{e.DOMException=function(a,u){this.message=a,this.name=u;var g=Error(a);this.stack=g.stack},e.DOMException.prototype=Object.create(Error.prototype),e.DOMException.prototype.constructor=e.DOMException}function A(n,a){return new Promise(function(u,g){var y=new F(n,a);if(y.signal&&y.signal.aborted)return g(new e.DOMException("Aborted","AbortError"));var f=new XMLHttpRequest;function D(){f.abort()}f.onload=function(){var v={statusText:f.statusText,headers:te(f.getAllResponseHeaders()||"")};y.url.indexOf("file://")===0&&(f.status<200||f.status>599)?v.status=200:v.status=f.status,v.url="responseURL"in f?f.responseURL:v.headers.get("X-Request-URL");var x="response"in f?f.response:f.responseText;setTimeout(function(){u(new w(x,v))},0)},f.onerror=function(){setTimeout(function(){g(new TypeError("Network request failed"))},0)},f.ontimeout=function(){setTimeout(function(){g(new TypeError("Network request timed out"))},0)},f.onabort=function(){setTimeout(function(){g(new e.DOMException("Aborted","AbortError"))},0)};function P(v){try{return v===""&&i.location.href?i.location.href:v}catch{return v}}if(f.open(y.method,P(y.url),!0),y.credentials==="include"?f.withCredentials=!0:y.credentials==="omit"&&(f.withCredentials=!1),"responseType"in f&&(s.blob?f.responseType="blob":s.arrayBuffer&&(f.responseType="arraybuffer")),a&&typeof a.headers=="object"&&!(a.headers instanceof h||i.Headers&&a.headers instanceof i.Headers)){var W=[];Object.getOwnPropertyNames(a.headers).forEach(function(v){W.push(d(v)),f.setRequestHeader(v,p(a.headers[v]))}),y.headers.forEach(function(v,x){W.indexOf(x)===-1&&f.setRequestHeader(x,v)})}else y.headers.forEach(function(v,x){f.setRequestHeader(x,v)});y.signal&&(y.signal.addEventListener("abort",D),f.onreadystatechange=function(){f.readyState===4&&y.signal.removeEventListener("abort",D)}),f.send(typeof y._bodyInit>"u"?null:y._bodyInit)})}return A.polyfill=!0,i.fetch||(i.fetch=A,i.Headers=h,i.Request=F,i.Response=w),e.Headers=h,e.Request=F,e.Response=w,e.fetch=A,e})({})})(typeof self<"u"?self:z)});var k=[];for(let r=0;r<256;++r)k.push((r+256).toString(16).slice(1));function H(r,t=0){return(k[r[t+0]]+k[r[t+1]]+k[r[t+2]]+k[r[t+3]]+"-"+k[r[t+4]]+k[r[t+5]]+"-"+k[r[t+6]]+k[r[t+7]]+"-"+k[r[t+8]]+k[r[t+9]]+"-"+k[r[t+10]]+k[r[t+11]]+k[r[t+12]]+k[r[t+13]]+k[r[t+14]]+k[r[t+15]]).toLowerCase()}var L,de=new Uint8Array(16);function B(){if(!L){if(typeof crypto>"u"||!crypto.getRandomValues)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");L=crypto.getRandomValues.bind(crypto)}return L(de)}var fe=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),N={randomUUID:fe};function he(r,t,e){r=r||{};let i=r.random??r.rng?.()??B();if(i.length<16)throw new Error("Random bytes length must be >= 16");if(i[6]=i[6]&15|64,i[8]=i[8]&63|128,t){if(e=e||0,e<0||e+16>t.length)throw new RangeError(`UUID byte range ${e}:${e+15} is out of buffer bounds`);for(let s=0;s<16;++s)t[e+s]=i[s];return t}return H(i)}function ge(r,t,e){return N.randomUUID&&!t&&!r?N.randomUUID():he(r,t,e)}var C=ge;var st=ue(K());function T(r){return pe(r,!1)}function pe(r,t){return r==null?r:{companyId:r.company_id==null?void 0:r.company_id,error:r.error==null?void 0:r.error,featureAllocation:r.feature_allocation==null?void 0:r.feature_allocation,featureUsage:r.feature_usage==null?void 0:r.feature_usage,featureUsageEvent:r.feature_usage_event==null?void 0:r.feature_usage_event,featureUsagePeriod:r.feature_usage_period==null?void 0:r.feature_usage_period,featureUsageResetAt:r.feature_usage_reset_at==null?void 0:new Date(r.feature_usage_reset_at),flag:r.flag,flagId:r.flag_id==null?void 0:r.flag_id,reason:r.reason,ruleId:r.rule_id==null?void 0:r.rule_id,ruleType:r.rule_type==null?void 0:r.rule_type,userId:r.user_id==null?void 0:r.user_id,value:r.value}}function M(r){return ye(r,!1)}function ye(r,t=!1){return r==null?r:{company_id:r.companyId,error:r.error,flag_id:r.flagId,flag_key:r.flagKey,reason:r.reason,req_company:r.reqCompany,req_user:r.reqUser,rule_id:r.ruleId,user_id:r.userId,value:r.value}}function U(r){return be(r,!1)}function be(r,t){return r==null?r:{data:T(r.data),params:r.params}}function X(r){return ve(r,!1)}function ve(r,t){return r==null?r:{flags:r.flags.map(T)}}function $(r){return ke(r,!1)}function ke(r,t){return r==null?r:{data:X(r.data),params:r.params}}var _=r=>{let{companyId:t,error:e,featureAllocation:i,featureUsage:s,featureUsageEvent:c,featureUsagePeriod:o,featureUsageResetAt:l,flag:d,flagId:p,reason:E,ruleId:h,ruleType:b,userId:m,value:S}=T(r);return{featureUsageExceeded:!S&&(b=="company_override_usage_exceeded"||b=="plan_entitlement_usage_exceeded"),companyId:t??void 0,error:e??void 0,featureAllocation:i??void 0,featureUsage:s??void 0,featureUsageEvent:c===null?void 0:c,featureUsagePeriod:o??void 0,featureUsageResetAt:l??void 0,flag:d,flagId:p??void 0,reason:E,ruleId:h??void 0,ruleType:b??void 0,userId:m??void 0,value:S}};function R(r){let t=Object.keys(r).reduce((e,i)=>{let c=Object.keys(r[i]||{}).sort().reduce((o,l)=>(o[l]=r[i][l],o),{});return e[i]=c,e},{});return JSON.stringify(t)}var O="1.2.8";var G="schematicId";var I=class{additionalHeaders={};apiKey;apiUrl="https://api.schematichq.com";conn=null;context={};debugEnabled=!1;offlineEnabled=!1;eventQueue;contextDependentEventQueue;eventUrl="https://c.schematichq.com";flagCheckListeners={};flagValueListeners={};isPending=!0;isPendingListeners=new Set;storage;useWebSocket=!1;checks={};featureUsageEventMap={};webSocketUrl="wss://api.schematichq.com";webSocketConnectionTimeout=1e4;webSocketReconnect=!0;webSocketMaxReconnectAttempts=7;webSocketInitialRetryDelay=1e3;webSocketMaxRetryDelay=3e4;wsReconnectAttempts=0;wsReconnectTimer=null;wsIntentionalDisconnect=!1;maxEventQueueSize=100;maxEventRetries=5;eventRetryInitialDelay=1e3;eventRetryMaxDelay=3e4;retryTimer=null;constructor(t,e){if(this.apiKey=t,this.eventQueue=[],this.contextDependentEventQueue=[],this.useWebSocket=e?.useWebSocket??!1,this.debugEnabled=e?.debug??!1,this.offlineEnabled=e?.offline??!1,typeof window<"u"&&typeof window.location<"u"){let i=new URLSearchParams(window.location.search),s=i.get("schematic_debug");s!==null&&(s===""||s==="true"||s==="1")&&(this.debugEnabled=!0);let c=i.get("schematic_offline");c!==null&&(c===""||c==="true"||c==="1")&&(this.offlineEnabled=!0,this.debugEnabled=!0)}this.offlineEnabled&&e?.debug!==!1&&(this.debugEnabled=!0),this.offlineEnabled&&this.setIsPending(!1),this.additionalHeaders={"X-Schematic-Client-Version":`schematic-js@${O}`,...e?.additionalHeaders??{}},e?.storage?this.storage=e.storage:typeof localStorage<"u"&&(this.storage=localStorage),e?.apiUrl!==void 0&&(this.apiUrl=e.apiUrl),e?.eventUrl!==void 0&&(this.eventUrl=e.eventUrl),e?.webSocketUrl!==void 0&&(this.webSocketUrl=e.webSocketUrl),e?.webSocketConnectionTimeout!==void 0&&(this.webSocketConnectionTimeout=e.webSocketConnectionTimeout),e?.webSocketReconnect!==void 0&&(this.webSocketReconnect=e.webSocketReconnect),e?.webSocketMaxReconnectAttempts!==void 0&&(this.webSocketMaxReconnectAttempts=e.webSocketMaxReconnectAttempts),e?.webSocketInitialRetryDelay!==void 0&&(this.webSocketInitialRetryDelay=e.webSocketInitialRetryDelay),e?.webSocketMaxRetryDelay!==void 0&&(this.webSocketMaxRetryDelay=e.webSocketMaxRetryDelay),e?.maxEventQueueSize!==void 0&&(this.maxEventQueueSize=e.maxEventQueueSize),e?.maxEventRetries!==void 0&&(this.maxEventRetries=e.maxEventRetries),e?.eventRetryInitialDelay!==void 0&&(this.eventRetryInitialDelay=e.eventRetryInitialDelay),e?.eventRetryMaxDelay!==void 0&&(this.eventRetryMaxDelay=e.eventRetryMaxDelay),typeof window<"u"&&window?.addEventListener&&(window.addEventListener("beforeunload",()=>{this.flushEventQueue(),this.flushContextDependentEventQueue()}),this.useWebSocket&&(window.addEventListener("offline",()=>{this.debug("Browser went offline, closing WebSocket connection"),this.handleNetworkOffline()}),window.addEventListener("online",()=>{this.debug("Browser came online, attempting to reconnect WebSocket"),this.handleNetworkOnline()}))),this.offlineEnabled?this.debug("Initialized with offline mode enabled - no network requests will be made"):this.debugEnabled&&this.debug("Initialized with debug mode enabled")}async checkFlag(t){let{fallback:e=!1,key:i}=t,s=t.context||this.context,c=R(s);if(this.debug(`checkFlag: ${i}`,{context:s,fallback:e}),this.isOffline())return this.debug(`checkFlag offline result: ${i}`,{value:e,offlineMode:!0}),e;if(!this.useWebSocket){let o=`${this.apiUrl}/flags/${i}/check`;return fetch(o,{method:"POST",headers:{...this.additionalHeaders??{},"Content-Type":"application/json;charset=UTF-8","X-Schematic-Api-Key":this.apiKey},body:JSON.stringify(s)}).then(l=>{if(!l.ok)throw new Error("Network response was not ok");return l.json()}).then(l=>{let d=U(l);this.debug(`checkFlag result: ${i}`,d);let p=_(d.data);return typeof p.featureUsageEvent=="string"&&this.updateFeatureUsageEventMap(p),this.submitFlagCheckEvent(i,p,s),p.value}).catch(l=>{console.error("There was a problem with the fetch operation:",l);let d={flag:i,value:e,reason:"API request failed",error:l instanceof Error?l.message:String(l)};return this.submitFlagCheckEvent(i,d,s),e})}try{let o=this.checks[c];if(this.conn!==null&&typeof o<"u"&&typeof o[i]<"u")return this.debug(`checkFlag cached result: ${i}`,o[i]),o[i].value;if(this.isOffline())return e;try{await this.setContext(s)}catch(E){return console.error("WebSocket connection failed, falling back to REST:",E),this.fallbackToRest(i,s,e)}let d=(this.checks[c]??{})[i],p=d?.value??e;return this.debug(`checkFlag WebSocket result: ${i}`,typeof d<"u"?d:{value:e,fallbackUsed:!0}),typeof d<"u"&&this.submitFlagCheckEvent(i,d,s),p}catch(o){console.error("Unexpected error in checkFlag:",o);let l={flag:i,value:e,reason:"Unexpected error in flag check",error:o instanceof Error?o.message:String(o)};return this.submitFlagCheckEvent(i,l,s),e}}debug(t,...e){this.debugEnabled&&console.log(`[Schematic] ${t}`,...e)}isOffline(){return this.offlineEnabled}submitFlagCheckEvent(t,e,i){let s={flagKey:t,value:e.value,reason:e.reason,flagId:e.flagId,ruleId:e.ruleId,companyId:e.companyId,userId:e.userId,error:e.error,reqCompany:i.company,reqUser:i.user};return this.debug("submitting flag check event:",s),this.handleEvent("flag_check",M(s))}async fallbackToRest(t,e,i){if(this.isOffline())return this.debug(`fallbackToRest offline result: ${t}`,{value:i,offlineMode:!0}),i;try{let s=`${this.apiUrl}/flags/${t}/check`,c=await fetch(s,{method:"POST",headers:{...this.additionalHeaders??{},"Content-Type":"application/json;charset=UTF-8","X-Schematic-Api-Key":this.apiKey},body:JSON.stringify(e)});if(!c.ok)throw new Error("Network response was not ok");let o=await c.json(),l=U(o);this.debug(`fallbackToRest result: ${t}`,l);let d=_(l.data);return typeof d.featureUsageEvent=="string"&&this.updateFeatureUsageEventMap(d),this.submitFlagCheckEvent(t,d,e),d.value}catch(s){console.error("REST API call failed, using fallback value:",s);let c={flag:t,value:i,reason:"API request failed (fallback)",error:s instanceof Error?s.message:String(s)};return this.submitFlagCheckEvent(t,c,e),i}}checkFlags=async t=>{if(t=t||this.context,this.debug("checkFlags",{context:t}),this.isOffline())return this.debug("checkFlags offline result: returning empty object"),{};let e=`${this.apiUrl}/flags/check`,i=JSON.stringify(t);return fetch(e,{method:"POST",headers:{...this.additionalHeaders??{},"Content-Type":"application/json;charset=UTF-8","X-Schematic-Api-Key":this.apiKey},body:i}).then(s=>{if(!s.ok)throw new Error("Network response was not ok");return s.json()}).then(s=>{let c=$(s);return this.debug("checkFlags result:",c),(c?.data?.flags??[]).reduce((o,l)=>(o[l.flag]=l.value,o),{})}).catch(s=>(console.error("There was a problem with the fetch operation:",s),{}))};identify=t=>{this.debug("identify:",t);try{this.setContext({company:t.company?.keys,user:t.keys})}catch(e){console.error("Error setting context:",e)}return this.handleEvent("identify",t)};setContext=async t=>{if(this.isOffline()||!this.useWebSocket)return this.context=t,this.flushContextDependentEventQueue(),this.setIsPending(!1),Promise.resolve();try{this.setIsPending(!0),this.conn||(this.wsReconnectTimer!==null&&(this.debug("Cancelling scheduled reconnection, connecting immediately"),clearTimeout(this.wsReconnectTimer),this.wsReconnectTimer=null),this.conn=this.wsConnect());let e=await this.conn;await this.wsSendMessage(e,t)}catch(e){throw console.error("Failed to establish WebSocket connection:",e),e}};track=t=>{let{company:e,user:i,event:s,traits:c,quantity:o=1}=t;if(!this.hasContext(e,i)){this.debug(`track: queuing event "${s}" until context is available`);let d={api_key:this.apiKey,body:{company:e,event:s,traits:c??{},user:i,quantity:o},sent_at:new Date().toISOString(),tracker_event_id:C(),tracker_user_id:this.getAnonymousId(),type:"track"};return this.contextDependentEventQueue.push(d),Promise.resolve()}let l={company:e??this.context.company,event:s,traits:c??{},user:i??this.context.user,quantity:o};return this.debug("track:",l),s in this.featureUsageEventMap&&this.optimisticallyUpdateFeatureUsage(s,o),this.handleEvent("track",l)};optimisticallyUpdateFeatureUsage=(t,e=1)=>{let i=this.featureUsageEventMap[t];i!=null&&(this.debug(`Optimistically updating feature usage for event: ${t}`,{quantity:e}),Object.entries(i).forEach(([s,c])=>{if(c===void 0)return;let o={...c};if(typeof o.featureUsage=="number"){if(o.featureUsage+=e,typeof o.featureAllocation=="number"){let d=o.featureUsageExceeded===!0,p=o.featureUsage>=o.featureAllocation;p!==d&&(o.featureUsageExceeded=p,p&&(o.value=!1),this.debug(`Usage limit status changed for flag: ${s}`,{was:d?"exceeded":"within limits",now:p?"exceeded":"within limits",featureUsage:o.featureUsage,featureAllocation:o.featureAllocation,value:o.value}))}this.featureUsageEventMap[t]!==void 0&&(this.featureUsageEventMap[t][s]=o);let l=R(this.context);this.checks[l]!==void 0&&this.checks[l]!==null&&(this.checks[l][s]=o),this.notifyFlagCheckListeners(s,o),this.notifyFlagValueListeners(s,o.value)}}))};hasContext=(t,e)=>{let i=t!=null&&Object.keys(t).length>0||e!=null&&Object.keys(e).length>0,s=this.context.company!==void 0&&this.context.company!==null&&Object.keys(this.context.company).length>0||this.context.user!==void 0&&this.context.user!==null&&Object.keys(this.context.user).length>0;return i||s};flushContextDependentEventQueue=()=>{for(this.debug(`flushing ${this.contextDependentEventQueue.length} context-dependent events`);this.contextDependentEventQueue.length>0;){let t=this.contextDependentEventQueue.shift();if(t)if(t.type==="track"&&typeof t.body=="object"&&t.body!==null){let e=t.body,i={...e,company:e.company??this.context.company,user:e.user??this.context.user},s={...t,body:i,sent_at:new Date().toISOString()};this.sendEvent(s)}else this.sendEvent(t)}};startRetryTimer=()=>{this.retryTimer===null&&(this.retryTimer=setInterval(()=>{this.flushEventQueue().catch(t=>{this.debug("Error in retry timer flush:",t)}),this.eventQueue.length===0&&this.stopRetryTimer()},5e3),this.debug("Started retry timer"))};stopRetryTimer=()=>{this.retryTimer!==null&&(clearInterval(this.retryTimer),this.retryTimer=null,this.debug("Stopped retry timer"))};flushEventQueue=async()=>{if(this.eventQueue.length===0)return;let t=Date.now(),e=[],i=[];for(let s of this.eventQueue)s.next_retry_at===void 0||s.next_retry_at<=t?e.push(s):i.push(s);if(e.length===0){this.debug(`No events ready for retry yet (${i.length} still in backoff)`);return}this.debug(`Flushing event queue: ${e.length} ready, ${i.length} waiting`),this.eventQueue=i;for(let s of e)try{await this.sendEvent(s),this.debug("Queued event sent successfully:",s.type)}catch(c){this.debug("Failed to send queued event:",c)}};getAnonymousId=()=>{if(!this.storage)return C();let t=this.storage.getItem(G);if(typeof t<"u")return t;let e=C();return this.storage.setItem(G,e),e};handleEvent=(t,e)=>{let i={api_key:this.apiKey,body:e,sent_at:new Date().toISOString(),tracker_event_id:C(),tracker_user_id:this.getAnonymousId(),type:t};return typeof document<"u"&&document?.hidden?this.storeEvent(i):this.sendEvent(i)};sendEvent=async t=>{let e=`${this.eventUrl}/e`,i=JSON.stringify(t);if(this.debug("sending event:",{url:e,event:t}),this.isOffline())return this.debug("event not sent (offline mode):",{event:t}),Promise.resolve();try{let s=await fetch(e,{method:"POST",headers:{...this.additionalHeaders??{},"Content-Type":"application/json;charset=UTF-8"},body:i});if(!s.ok)throw new Error(`HTTP ${s.status}: ${s.statusText}`);this.debug("event sent:",{status:s.status,statusText:s.statusText})}catch(s){let c=(t.retry_count??0)+1;if(c<=this.maxEventRetries){this.debug(`Event failed to send (attempt ${c}/${this.maxEventRetries}), queueing for retry:`,s);let o=this.eventRetryInitialDelay*Math.pow(2,c-1),l=Math.min(o,this.eventRetryMaxDelay),d=Date.now()+l,p={...t,retry_count:c,next_retry_at:d};this.eventQueue.length<this.maxEventQueueSize?(this.eventQueue.push(p),this.debug(`Event queued for retry in ${l}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`)):(this.debug(`Event queue full (${this.maxEventQueueSize}), dropping oldest event`),this.eventQueue.shift(),this.eventQueue.push(p)),this.startRetryTimer()}else this.debug(`Event failed permanently after ${this.maxEventRetries} attempts, dropping:`,s)}return Promise.resolve()};storeEvent=t=>(this.eventQueue.push(t),Promise.resolve());cleanup=async()=>{if(this.isOffline())return this.debug("cleanup: skipped (offline mode)"),Promise.resolve();if(this.wsIntentionalDisconnect=!0,this.wsReconnectTimer!==null&&(clearTimeout(this.wsReconnectTimer),this.wsReconnectTimer=null),this.stopRetryTimer(),this.conn)try{(await this.conn).close()}catch(t){console.error("Error during cleanup:",t)}finally{this.conn=null}};calculateReconnectDelay=()=>{let t=this.webSocketInitialRetryDelay*Math.pow(2,this.wsReconnectAttempts),e=Math.min(t,this.webSocketMaxRetryDelay),i=Math.random()*e*.5,s=e+i;return this.debug(`Reconnect delay calculated: ${s.toFixed(0)}ms (attempt ${this.wsReconnectAttempts+1}/${this.webSocketMaxReconnectAttempts})`),s};handleNetworkOffline=async()=>{if(this.conn!==null){try{(await this.conn).close()}catch(t){this.debug("Error closing connection on offline:",t)}this.conn=null}this.wsReconnectTimer!==null&&(clearTimeout(this.wsReconnectTimer),this.wsReconnectTimer=null)};handleNetworkOnline=()=>{this.debug("Network online, attempting reconnection and flushing queued events"),this.wsReconnectAttempts=0,this.wsReconnectTimer!==null&&(clearTimeout(this.wsReconnectTimer),this.wsReconnectTimer=null),this.flushEventQueue().catch(t=>{this.debug("Error flushing event queue on network online:",t)}),this.attemptReconnect()};attemptReconnect=()=>{if(this.wsReconnectAttempts>=this.webSocketMaxReconnectAttempts){this.debug(`Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`);return}this.wsReconnectTimer!==null&&clearTimeout(this.wsReconnectTimer);let t=this.calculateReconnectDelay();this.debug(`Scheduling reconnection attempt ${this.wsReconnectAttempts+1}/${this.webSocketMaxReconnectAttempts} in ${t.toFixed(0)}ms`),this.wsReconnectTimer=setTimeout(async()=>{this.wsReconnectTimer=null,this.wsReconnectAttempts++,this.debug(`Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`);try{this.conn=this.wsConnect();let e=await this.conn;this.debug("Reconnection context check:",{hasCompany:this.context.company!==void 0,hasUser:this.context.user!==void 0,context:this.context}),this.context.company!==void 0||this.context.user!==void 0?(this.debug("Reconnected, force re-sending context"),await this.wsSendContextAfterReconnection(e,this.context)):this.debug("No context to re-send after reconnection - websocket ready for new context"),this.flushEventQueue().catch(i=>{this.debug("Error flushing event queue after websocket reconnection:",i)}),this.debug("Reconnection successful")}catch(e){this.debug("Reconnection attempt failed:",e)}},t)};wsConnect=()=>this.isOffline()?(this.debug("wsConnect: skipped (offline mode)"),Promise.reject(new Error("WebSocket connection skipped in offline mode"))):new Promise((t,e)=>{let i=`${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;this.debug("connecting to WebSocket:",i);let s=new WebSocket(i),c=null,o=!1;c=setTimeout(()=>{o||(this.debug(`WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`),s.close(),e(new Error("WebSocket connection timeout")))},this.webSocketConnectionTimeout),s.onopen=()=>{o=!0,c!==null&&clearTimeout(c),this.wsReconnectAttempts=0,this.wsIntentionalDisconnect=!1,this.debug("WebSocket connection opened"),t(s)},s.onerror=l=>{o=!0,c!==null&&clearTimeout(c),this.debug("WebSocket connection error:",l),e(l)},s.onclose=()=>{o=!0,c!==null&&clearTimeout(c),this.debug("WebSocket connection closed"),this.conn=null,!this.wsIntentionalDisconnect&&this.webSocketReconnect&&this.attemptReconnect()}});wsSendContextAfterReconnection=(t,e)=>this.isOffline()?(this.debug("wsSendContextAfterReconnection: skipped (offline mode)"),this.setIsPending(!1),Promise.resolve()):new Promise(i=>{this.debug("WebSocket force sending context after reconnection:",e),this.context=e;let s=()=>{let c=!1,o=p=>{let E=JSON.parse(p.data);this.debug("WebSocket message received after reconnection:",E),R(e)in this.checks||(this.checks[R(e)]={}),(E.flags??[]).forEach(h=>{let b=_(h),m=R(e);this.checks[m]===void 0&&(this.checks[m]={}),this.checks[m][b.flag]=b}),this.useWebSocket=!0,t.removeEventListener("message",o),c||(c=!0,i(this.setIsPending(!1)))};t.addEventListener("message",o);let l=this.additionalHeaders["X-Schematic-Client-Version"]??`schematic-js@${O}`,d={apiKey:this.apiKey,clientVersion:l,data:e};this.debug("WebSocket sending forced message after reconnection:",d),t.send(JSON.stringify(d))};t.readyState===WebSocket.OPEN?(this.debug("WebSocket already open, sending forced message after reconnection"),s()):t.addEventListener("open",()=>{this.debug("WebSocket opened, sending forced message after reconnection"),s()})});wsSendMessage=(t,e)=>this.isOffline()?(this.debug("wsSendMessage: skipped (offline mode)"),this.setIsPending(!1),Promise.resolve()):new Promise((i,s)=>{if(R(e)==R(this.context))return this.debug("WebSocket context unchanged, skipping update"),i(this.setIsPending(!1));this.debug("WebSocket context updated:",e),this.context=e;let c=()=>{let o=!1,l=E=>{let h=JSON.parse(E.data);this.debug("WebSocket message received:",h),R(e)in this.checks||(this.checks[R(e)]={}),(h.flags??[]).forEach(b=>{let m=_(b),S=R(e);this.checks[S]===void 0&&(this.checks[S]={}),this.checks[S][m.flag]=m,this.debug("WebSocket flag update:",{flag:m.flag,value:m.value,flagCheck:m}),typeof m.featureUsageEvent=="string"&&this.updateFeatureUsageEventMap(m),(this.flagCheckListeners[b.flag]?.size>0||this.flagValueListeners[b.flag]?.size>0)&&this.submitFlagCheckEvent(m.flag,m,e),this.notifyFlagCheckListeners(b.flag,m),this.notifyFlagValueListeners(b.flag,m.value)}),this.flushContextDependentEventQueue(),this.setIsPending(!1),o||(o=!0,i())};t.addEventListener("message",l);let d=this.additionalHeaders["X-Schematic-Client-Version"]??`schematic-js@${O}`,p={apiKey:this.apiKey,clientVersion:d,data:e};this.debug("WebSocket sending message:",p),t.send(JSON.stringify(p))};t.readyState===WebSocket.OPEN?(this.debug("WebSocket already open, sending message"),c()):t.readyState===WebSocket.CONNECTING?(this.debug("WebSocket connecting, waiting for open to send message"),t.addEventListener("open",c)):(this.debug("WebSocket is closed, cannot send message"),s("WebSocket is not open or connecting"))});getIsPending=()=>this.isPending;addIsPendingListener=t=>(this.isPendingListeners.add(t),()=>{this.isPendingListeners.delete(t)});setIsPending=t=>{this.isPending=t,this.isPendingListeners.forEach(e=>Ee(e,t))};getFlagCheck=t=>{let e=R(this.context);return(this.checks[e]??{})[t]};getFlagValue=t=>this.getFlagCheck(t)?.value;addFlagValueListener=(t,e)=>(t in this.flagValueListeners||(this.flagValueListeners[t]=new Set),this.flagValueListeners[t].add(e),()=>{this.flagValueListeners[t].delete(e)});addFlagCheckListener=(t,e)=>(t in this.flagCheckListeners||(this.flagCheckListeners[t]=new Set),this.flagCheckListeners[t].add(e),()=>{this.flagCheckListeners[t].delete(e)});notifyFlagCheckListeners=(t,e)=>{let i=this.flagCheckListeners?.[t]??[];i.size>0&&this.debug(`Notifying ${i.size} flag check listeners for ${t}`,e),typeof e.featureUsageEvent=="string"&&this.updateFeatureUsageEventMap(e),i.forEach(s=>Re(s,e))};updateFeatureUsageEventMap=t=>{if(typeof t.featureUsageEvent!="string")return;let e=t.featureUsageEvent;(this.featureUsageEventMap[e]===void 0||this.featureUsageEventMap[e]===null)&&(this.featureUsageEventMap[e]={}),this.featureUsageEventMap[e]!==void 0&&(this.featureUsageEventMap[e][t.flag]=t),this.debug(`Updated featureUsageEventMap for event: ${e}, flag: ${t.flag}`,t)};notifyFlagValueListeners=(t,e)=>{let i=this.flagValueListeners?.[t]??[];i.size>0&&this.debug(`Notifying ${i.size} flag value listeners for ${t}`,{value:e}),i.forEach(s=>we(s,e))}},Ee=(r,t)=>{r.length>0?r(t):r()},Re=(r,t)=>{r.length>0?r(t):r()},we=(r,t)=>{r.length>0?r(t):r()};window.Schematic=I;})();
|
|
3
3
|
/* @preserve */
|
package/dist/schematic.cjs.js
CHANGED
|
@@ -800,7 +800,7 @@ function contextString(context) {
|
|
|
800
800
|
}
|
|
801
801
|
|
|
802
802
|
// src/version.ts
|
|
803
|
-
var version = "1.2.
|
|
803
|
+
var version = "1.2.8";
|
|
804
804
|
|
|
805
805
|
// src/index.ts
|
|
806
806
|
var anonymousIdKey = "schematicId";
|
|
@@ -824,6 +824,23 @@ var Schematic = class {
|
|
|
824
824
|
checks = {};
|
|
825
825
|
featureUsageEventMap = {};
|
|
826
826
|
webSocketUrl = "wss://api.schematichq.com";
|
|
827
|
+
webSocketConnectionTimeout = 1e4;
|
|
828
|
+
webSocketReconnect = true;
|
|
829
|
+
webSocketMaxReconnectAttempts = 7;
|
|
830
|
+
webSocketInitialRetryDelay = 1e3;
|
|
831
|
+
webSocketMaxRetryDelay = 3e4;
|
|
832
|
+
wsReconnectAttempts = 0;
|
|
833
|
+
wsReconnectTimer = null;
|
|
834
|
+
wsIntentionalDisconnect = false;
|
|
835
|
+
maxEventQueueSize = 100;
|
|
836
|
+
// Prevent memory issues with very long network outages
|
|
837
|
+
maxEventRetries = 5;
|
|
838
|
+
// Maximum retry attempts for failed events
|
|
839
|
+
eventRetryInitialDelay = 1e3;
|
|
840
|
+
// Initial retry delay in ms
|
|
841
|
+
eventRetryMaxDelay = 3e4;
|
|
842
|
+
// Maximum retry delay in ms
|
|
843
|
+
retryTimer = null;
|
|
827
844
|
constructor(apiKey, options) {
|
|
828
845
|
this.apiKey = apiKey;
|
|
829
846
|
this.eventQueue = [];
|
|
@@ -867,11 +884,48 @@ var Schematic = class {
|
|
|
867
884
|
if (options?.webSocketUrl !== void 0) {
|
|
868
885
|
this.webSocketUrl = options.webSocketUrl;
|
|
869
886
|
}
|
|
887
|
+
if (options?.webSocketConnectionTimeout !== void 0) {
|
|
888
|
+
this.webSocketConnectionTimeout = options.webSocketConnectionTimeout;
|
|
889
|
+
}
|
|
890
|
+
if (options?.webSocketReconnect !== void 0) {
|
|
891
|
+
this.webSocketReconnect = options.webSocketReconnect;
|
|
892
|
+
}
|
|
893
|
+
if (options?.webSocketMaxReconnectAttempts !== void 0) {
|
|
894
|
+
this.webSocketMaxReconnectAttempts = options.webSocketMaxReconnectAttempts;
|
|
895
|
+
}
|
|
896
|
+
if (options?.webSocketInitialRetryDelay !== void 0) {
|
|
897
|
+
this.webSocketInitialRetryDelay = options.webSocketInitialRetryDelay;
|
|
898
|
+
}
|
|
899
|
+
if (options?.webSocketMaxRetryDelay !== void 0) {
|
|
900
|
+
this.webSocketMaxRetryDelay = options.webSocketMaxRetryDelay;
|
|
901
|
+
}
|
|
902
|
+
if (options?.maxEventQueueSize !== void 0) {
|
|
903
|
+
this.maxEventQueueSize = options.maxEventQueueSize;
|
|
904
|
+
}
|
|
905
|
+
if (options?.maxEventRetries !== void 0) {
|
|
906
|
+
this.maxEventRetries = options.maxEventRetries;
|
|
907
|
+
}
|
|
908
|
+
if (options?.eventRetryInitialDelay !== void 0) {
|
|
909
|
+
this.eventRetryInitialDelay = options.eventRetryInitialDelay;
|
|
910
|
+
}
|
|
911
|
+
if (options?.eventRetryMaxDelay !== void 0) {
|
|
912
|
+
this.eventRetryMaxDelay = options.eventRetryMaxDelay;
|
|
913
|
+
}
|
|
870
914
|
if (typeof window !== "undefined" && window?.addEventListener) {
|
|
871
915
|
window.addEventListener("beforeunload", () => {
|
|
872
916
|
this.flushEventQueue();
|
|
873
917
|
this.flushContextDependentEventQueue();
|
|
874
918
|
});
|
|
919
|
+
if (this.useWebSocket) {
|
|
920
|
+
window.addEventListener("offline", () => {
|
|
921
|
+
this.debug("Browser went offline, closing WebSocket connection");
|
|
922
|
+
this.handleNetworkOffline();
|
|
923
|
+
});
|
|
924
|
+
window.addEventListener("online", () => {
|
|
925
|
+
this.debug("Browser came online, attempting to reconnect WebSocket");
|
|
926
|
+
this.handleNetworkOnline();
|
|
927
|
+
});
|
|
928
|
+
}
|
|
875
929
|
}
|
|
876
930
|
if (this.offlineEnabled) {
|
|
877
931
|
this.debug(
|
|
@@ -1136,6 +1190,13 @@ var Schematic = class {
|
|
|
1136
1190
|
try {
|
|
1137
1191
|
this.setIsPending(true);
|
|
1138
1192
|
if (!this.conn) {
|
|
1193
|
+
if (this.wsReconnectTimer !== null) {
|
|
1194
|
+
this.debug(
|
|
1195
|
+
`Cancelling scheduled reconnection, connecting immediately`
|
|
1196
|
+
);
|
|
1197
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1198
|
+
this.wsReconnectTimer = null;
|
|
1199
|
+
}
|
|
1139
1200
|
this.conn = this.wsConnect();
|
|
1140
1201
|
}
|
|
1141
1202
|
const socket = await this.conn;
|
|
@@ -1265,11 +1326,53 @@ var Schematic = class {
|
|
|
1265
1326
|
}
|
|
1266
1327
|
}
|
|
1267
1328
|
};
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1329
|
+
startRetryTimer = () => {
|
|
1330
|
+
if (this.retryTimer !== null) {
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
this.retryTimer = setInterval(() => {
|
|
1334
|
+
this.flushEventQueue().catch((error) => {
|
|
1335
|
+
this.debug("Error in retry timer flush:", error);
|
|
1336
|
+
});
|
|
1337
|
+
if (this.eventQueue.length === 0) {
|
|
1338
|
+
this.stopRetryTimer();
|
|
1339
|
+
}
|
|
1340
|
+
}, 5e3);
|
|
1341
|
+
this.debug("Started retry timer");
|
|
1342
|
+
};
|
|
1343
|
+
stopRetryTimer = () => {
|
|
1344
|
+
if (this.retryTimer !== null) {
|
|
1345
|
+
clearInterval(this.retryTimer);
|
|
1346
|
+
this.retryTimer = null;
|
|
1347
|
+
this.debug("Stopped retry timer");
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
flushEventQueue = async () => {
|
|
1351
|
+
if (this.eventQueue.length === 0) {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
const now = Date.now();
|
|
1355
|
+
const readyEvents = [];
|
|
1356
|
+
const notReadyEvents = [];
|
|
1357
|
+
for (const event of this.eventQueue) {
|
|
1358
|
+
if (event.next_retry_at === void 0 || event.next_retry_at <= now) {
|
|
1359
|
+
readyEvents.push(event);
|
|
1360
|
+
} else {
|
|
1361
|
+
notReadyEvents.push(event);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
if (readyEvents.length === 0) {
|
|
1365
|
+
this.debug(`No events ready for retry yet (${notReadyEvents.length} still in backoff)`);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
this.debug(`Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`);
|
|
1369
|
+
this.eventQueue = notReadyEvents;
|
|
1370
|
+
for (const event of readyEvents) {
|
|
1371
|
+
try {
|
|
1372
|
+
await this.sendEvent(event);
|
|
1373
|
+
this.debug(`Queued event sent successfully:`, event.type);
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
this.debug(`Failed to send queued event:`, error);
|
|
1273
1376
|
}
|
|
1274
1377
|
}
|
|
1275
1378
|
};
|
|
@@ -1317,12 +1420,37 @@ var Schematic = class {
|
|
|
1317
1420
|
},
|
|
1318
1421
|
body: payload
|
|
1319
1422
|
});
|
|
1423
|
+
if (!response.ok) {
|
|
1424
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1425
|
+
}
|
|
1320
1426
|
this.debug(`event sent:`, {
|
|
1321
1427
|
status: response.status,
|
|
1322
1428
|
statusText: response.statusText
|
|
1323
1429
|
});
|
|
1324
1430
|
} catch (error) {
|
|
1325
|
-
|
|
1431
|
+
const retryCount = (event.retry_count ?? 0) + 1;
|
|
1432
|
+
if (retryCount <= this.maxEventRetries) {
|
|
1433
|
+
this.debug(`Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`, error);
|
|
1434
|
+
const baseDelay = this.eventRetryInitialDelay * Math.pow(2, retryCount - 1);
|
|
1435
|
+
const jitterDelay = Math.min(baseDelay, this.eventRetryMaxDelay);
|
|
1436
|
+
const nextRetryAt = Date.now() + jitterDelay;
|
|
1437
|
+
const retryEvent = {
|
|
1438
|
+
...event,
|
|
1439
|
+
retry_count: retryCount,
|
|
1440
|
+
next_retry_at: nextRetryAt
|
|
1441
|
+
};
|
|
1442
|
+
if (this.eventQueue.length < this.maxEventQueueSize) {
|
|
1443
|
+
this.eventQueue.push(retryEvent);
|
|
1444
|
+
this.debug(`Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`);
|
|
1445
|
+
} else {
|
|
1446
|
+
this.debug(`Event queue full (${this.maxEventQueueSize}), dropping oldest event`);
|
|
1447
|
+
this.eventQueue.shift();
|
|
1448
|
+
this.eventQueue.push(retryEvent);
|
|
1449
|
+
}
|
|
1450
|
+
this.startRetryTimer();
|
|
1451
|
+
} else {
|
|
1452
|
+
this.debug(`Event failed permanently after ${this.maxEventRetries} attempts, dropping:`, error);
|
|
1453
|
+
}
|
|
1326
1454
|
}
|
|
1327
1455
|
return Promise.resolve();
|
|
1328
1456
|
};
|
|
@@ -1342,6 +1470,12 @@ var Schematic = class {
|
|
|
1342
1470
|
this.debug("cleanup: skipped (offline mode)");
|
|
1343
1471
|
return Promise.resolve();
|
|
1344
1472
|
}
|
|
1473
|
+
this.wsIntentionalDisconnect = true;
|
|
1474
|
+
if (this.wsReconnectTimer !== null) {
|
|
1475
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1476
|
+
this.wsReconnectTimer = null;
|
|
1477
|
+
}
|
|
1478
|
+
this.stopRetryTimer();
|
|
1345
1479
|
if (this.conn) {
|
|
1346
1480
|
try {
|
|
1347
1481
|
const socket = await this.conn;
|
|
@@ -1353,6 +1487,100 @@ var Schematic = class {
|
|
|
1353
1487
|
}
|
|
1354
1488
|
}
|
|
1355
1489
|
};
|
|
1490
|
+
/**
|
|
1491
|
+
* Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
|
|
1492
|
+
* This helps prevent dogpiling when the server recovers from an outage.
|
|
1493
|
+
*/
|
|
1494
|
+
calculateReconnectDelay = () => {
|
|
1495
|
+
const exponentialDelay = this.webSocketInitialRetryDelay * Math.pow(2, this.wsReconnectAttempts);
|
|
1496
|
+
const cappedDelay = Math.min(exponentialDelay, this.webSocketMaxRetryDelay);
|
|
1497
|
+
const jitter = Math.random() * cappedDelay * 0.5;
|
|
1498
|
+
const totalDelay = cappedDelay + jitter;
|
|
1499
|
+
this.debug(
|
|
1500
|
+
`Reconnect delay calculated: ${totalDelay.toFixed(0)}ms (attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts})`
|
|
1501
|
+
);
|
|
1502
|
+
return totalDelay;
|
|
1503
|
+
};
|
|
1504
|
+
/**
|
|
1505
|
+
* Handle browser going offline
|
|
1506
|
+
*/
|
|
1507
|
+
handleNetworkOffline = async () => {
|
|
1508
|
+
if (this.conn !== null) {
|
|
1509
|
+
try {
|
|
1510
|
+
const socket = await this.conn;
|
|
1511
|
+
socket.close();
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
this.debug("Error closing connection on offline:", error);
|
|
1514
|
+
}
|
|
1515
|
+
this.conn = null;
|
|
1516
|
+
}
|
|
1517
|
+
if (this.wsReconnectTimer !== null) {
|
|
1518
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1519
|
+
this.wsReconnectTimer = null;
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
/**
|
|
1523
|
+
* Handle browser coming back online
|
|
1524
|
+
*/
|
|
1525
|
+
handleNetworkOnline = () => {
|
|
1526
|
+
this.debug("Network online, attempting reconnection and flushing queued events");
|
|
1527
|
+
this.wsReconnectAttempts = 0;
|
|
1528
|
+
if (this.wsReconnectTimer !== null) {
|
|
1529
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1530
|
+
this.wsReconnectTimer = null;
|
|
1531
|
+
}
|
|
1532
|
+
this.flushEventQueue().catch((error) => {
|
|
1533
|
+
this.debug("Error flushing event queue on network online:", error);
|
|
1534
|
+
});
|
|
1535
|
+
this.attemptReconnect();
|
|
1536
|
+
};
|
|
1537
|
+
/**
|
|
1538
|
+
* Attempt to reconnect the WebSocket connection with exponential backoff.
|
|
1539
|
+
* Called automatically when the connection closes unexpectedly.
|
|
1540
|
+
*/
|
|
1541
|
+
attemptReconnect = () => {
|
|
1542
|
+
if (this.wsReconnectAttempts >= this.webSocketMaxReconnectAttempts) {
|
|
1543
|
+
this.debug(
|
|
1544
|
+
`Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`
|
|
1545
|
+
);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (this.wsReconnectTimer !== null) {
|
|
1549
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1550
|
+
}
|
|
1551
|
+
const delay = this.calculateReconnectDelay();
|
|
1552
|
+
this.debug(
|
|
1553
|
+
`Scheduling reconnection attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts} in ${delay.toFixed(0)}ms`
|
|
1554
|
+
);
|
|
1555
|
+
this.wsReconnectTimer = setTimeout(async () => {
|
|
1556
|
+
this.wsReconnectTimer = null;
|
|
1557
|
+
this.wsReconnectAttempts++;
|
|
1558
|
+
this.debug(
|
|
1559
|
+
`Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
|
|
1560
|
+
);
|
|
1561
|
+
try {
|
|
1562
|
+
this.conn = this.wsConnect();
|
|
1563
|
+
const socket = await this.conn;
|
|
1564
|
+
this.debug(`Reconnection context check:`, {
|
|
1565
|
+
hasCompany: this.context.company !== void 0,
|
|
1566
|
+
hasUser: this.context.user !== void 0,
|
|
1567
|
+
context: this.context
|
|
1568
|
+
});
|
|
1569
|
+
if (this.context.company !== void 0 || this.context.user !== void 0) {
|
|
1570
|
+
this.debug(`Reconnected, force re-sending context`);
|
|
1571
|
+
await this.wsSendContextAfterReconnection(socket, this.context);
|
|
1572
|
+
} else {
|
|
1573
|
+
this.debug(`No context to re-send after reconnection - websocket ready for new context`);
|
|
1574
|
+
}
|
|
1575
|
+
this.flushEventQueue().catch((error) => {
|
|
1576
|
+
this.debug("Error flushing event queue after websocket reconnection:", error);
|
|
1577
|
+
});
|
|
1578
|
+
this.debug(`Reconnection successful`);
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
this.debug(`Reconnection attempt failed:`, error);
|
|
1581
|
+
}
|
|
1582
|
+
}, delay);
|
|
1583
|
+
};
|
|
1356
1584
|
// Open a websocket connection
|
|
1357
1585
|
wsConnect = () => {
|
|
1358
1586
|
if (this.isOffline()) {
|
|
@@ -1365,20 +1593,103 @@ var Schematic = class {
|
|
|
1365
1593
|
const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
|
|
1366
1594
|
this.debug(`connecting to WebSocket:`, wsUrl);
|
|
1367
1595
|
const webSocket = new WebSocket(wsUrl);
|
|
1596
|
+
let timeoutId = null;
|
|
1597
|
+
let isResolved = false;
|
|
1598
|
+
timeoutId = setTimeout(() => {
|
|
1599
|
+
if (!isResolved) {
|
|
1600
|
+
this.debug(
|
|
1601
|
+
`WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`
|
|
1602
|
+
);
|
|
1603
|
+
webSocket.close();
|
|
1604
|
+
reject(new Error("WebSocket connection timeout"));
|
|
1605
|
+
}
|
|
1606
|
+
}, this.webSocketConnectionTimeout);
|
|
1368
1607
|
webSocket.onopen = () => {
|
|
1608
|
+
isResolved = true;
|
|
1609
|
+
if (timeoutId !== null) {
|
|
1610
|
+
clearTimeout(timeoutId);
|
|
1611
|
+
}
|
|
1612
|
+
this.wsReconnectAttempts = 0;
|
|
1613
|
+
this.wsIntentionalDisconnect = false;
|
|
1369
1614
|
this.debug(`WebSocket connection opened`);
|
|
1370
1615
|
resolve(webSocket);
|
|
1371
1616
|
};
|
|
1372
1617
|
webSocket.onerror = (error) => {
|
|
1618
|
+
isResolved = true;
|
|
1619
|
+
if (timeoutId !== null) {
|
|
1620
|
+
clearTimeout(timeoutId);
|
|
1621
|
+
}
|
|
1373
1622
|
this.debug(`WebSocket connection error:`, error);
|
|
1374
1623
|
reject(error);
|
|
1375
1624
|
};
|
|
1376
1625
|
webSocket.onclose = () => {
|
|
1626
|
+
isResolved = true;
|
|
1627
|
+
if (timeoutId !== null) {
|
|
1628
|
+
clearTimeout(timeoutId);
|
|
1629
|
+
}
|
|
1377
1630
|
this.debug(`WebSocket connection closed`);
|
|
1378
1631
|
this.conn = null;
|
|
1632
|
+
if (!this.wsIntentionalDisconnect && this.webSocketReconnect) {
|
|
1633
|
+
this.attemptReconnect();
|
|
1634
|
+
}
|
|
1379
1635
|
};
|
|
1380
1636
|
});
|
|
1381
1637
|
};
|
|
1638
|
+
// Send a message on the websocket after reconnection, forcing the send even if context appears unchanged
|
|
1639
|
+
// because the server has lost all state and needs the initial context
|
|
1640
|
+
wsSendContextAfterReconnection = (socket, context) => {
|
|
1641
|
+
if (this.isOffline()) {
|
|
1642
|
+
this.debug("wsSendContextAfterReconnection: skipped (offline mode)");
|
|
1643
|
+
this.setIsPending(false);
|
|
1644
|
+
return Promise.resolve();
|
|
1645
|
+
}
|
|
1646
|
+
return new Promise((resolve) => {
|
|
1647
|
+
this.debug(`WebSocket force sending context after reconnection:`, context);
|
|
1648
|
+
this.context = context;
|
|
1649
|
+
const sendMessage = () => {
|
|
1650
|
+
let resolved = false;
|
|
1651
|
+
const messageHandler = (event) => {
|
|
1652
|
+
const message = JSON.parse(event.data);
|
|
1653
|
+
this.debug(`WebSocket message received after reconnection:`, message);
|
|
1654
|
+
if (!(contextString(context) in this.checks)) {
|
|
1655
|
+
this.checks[contextString(context)] = {};
|
|
1656
|
+
}
|
|
1657
|
+
(message.flags ?? []).forEach((flag) => {
|
|
1658
|
+
const flagCheck = CheckFlagReturnFromJSON(flag);
|
|
1659
|
+
const contextStr = contextString(context);
|
|
1660
|
+
if (this.checks[contextStr] === void 0) {
|
|
1661
|
+
this.checks[contextStr] = {};
|
|
1662
|
+
}
|
|
1663
|
+
this.checks[contextStr][flagCheck.flag] = flagCheck;
|
|
1664
|
+
});
|
|
1665
|
+
this.useWebSocket = true;
|
|
1666
|
+
socket.removeEventListener("message", messageHandler);
|
|
1667
|
+
if (!resolved) {
|
|
1668
|
+
resolved = true;
|
|
1669
|
+
resolve(this.setIsPending(false));
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
socket.addEventListener("message", messageHandler);
|
|
1673
|
+
const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
|
|
1674
|
+
const messagePayload = {
|
|
1675
|
+
apiKey: this.apiKey,
|
|
1676
|
+
clientVersion,
|
|
1677
|
+
data: context
|
|
1678
|
+
};
|
|
1679
|
+
this.debug(`WebSocket sending forced message after reconnection:`, messagePayload);
|
|
1680
|
+
socket.send(JSON.stringify(messagePayload));
|
|
1681
|
+
};
|
|
1682
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
1683
|
+
this.debug(`WebSocket already open, sending forced message after reconnection`);
|
|
1684
|
+
sendMessage();
|
|
1685
|
+
} else {
|
|
1686
|
+
socket.addEventListener("open", () => {
|
|
1687
|
+
this.debug(`WebSocket opened, sending forced message after reconnection`);
|
|
1688
|
+
sendMessage();
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
};
|
|
1382
1693
|
// Send a message on the websocket indicating interest in a particular evaluation context
|
|
1383
1694
|
// and wait for the initial set of flag values to be returned
|
|
1384
1695
|
wsSendMessage = (socket, context) => {
|
package/dist/schematic.d.ts
CHANGED
|
@@ -213,6 +213,8 @@ declare type Event_2 = {
|
|
|
213
213
|
tracker_event_id: string;
|
|
214
214
|
tracker_user_id: string;
|
|
215
215
|
type: EventType;
|
|
216
|
+
retry_count?: number;
|
|
217
|
+
next_retry_at?: number;
|
|
216
218
|
};
|
|
217
219
|
export { Event_2 as Event }
|
|
218
220
|
|
|
@@ -368,6 +370,19 @@ export declare class Schematic {
|
|
|
368
370
|
private checks;
|
|
369
371
|
private featureUsageEventMap;
|
|
370
372
|
private webSocketUrl;
|
|
373
|
+
private webSocketConnectionTimeout;
|
|
374
|
+
private webSocketReconnect;
|
|
375
|
+
private webSocketMaxReconnectAttempts;
|
|
376
|
+
private webSocketInitialRetryDelay;
|
|
377
|
+
private webSocketMaxRetryDelay;
|
|
378
|
+
private wsReconnectAttempts;
|
|
379
|
+
private wsReconnectTimer;
|
|
380
|
+
private wsIntentionalDisconnect;
|
|
381
|
+
private maxEventQueueSize;
|
|
382
|
+
private maxEventRetries;
|
|
383
|
+
private eventRetryInitialDelay;
|
|
384
|
+
private eventRetryMaxDelay;
|
|
385
|
+
private retryTimer;
|
|
371
386
|
constructor(apiKey: string, options?: SchematicOptions);
|
|
372
387
|
/**
|
|
373
388
|
* Get value for a single flag.
|
|
@@ -434,6 +449,8 @@ export declare class Schematic {
|
|
|
434
449
|
*/
|
|
435
450
|
private hasContext;
|
|
436
451
|
private flushContextDependentEventQueue;
|
|
452
|
+
private startRetryTimer;
|
|
453
|
+
private stopRetryTimer;
|
|
437
454
|
private flushEventQueue;
|
|
438
455
|
private getAnonymousId;
|
|
439
456
|
private handleEvent;
|
|
@@ -447,7 +464,26 @@ export declare class Schematic {
|
|
|
447
464
|
* In offline mode, this is a no-op.
|
|
448
465
|
*/
|
|
449
466
|
cleanup: () => Promise<void>;
|
|
467
|
+
/**
|
|
468
|
+
* Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
|
|
469
|
+
* This helps prevent dogpiling when the server recovers from an outage.
|
|
470
|
+
*/
|
|
471
|
+
private calculateReconnectDelay;
|
|
472
|
+
/**
|
|
473
|
+
* Handle browser going offline
|
|
474
|
+
*/
|
|
475
|
+
private handleNetworkOffline;
|
|
476
|
+
/**
|
|
477
|
+
* Handle browser coming back online
|
|
478
|
+
*/
|
|
479
|
+
private handleNetworkOnline;
|
|
480
|
+
/**
|
|
481
|
+
* Attempt to reconnect the WebSocket connection with exponential backoff.
|
|
482
|
+
* Called automatically when the connection closes unexpectedly.
|
|
483
|
+
*/
|
|
484
|
+
private attemptReconnect;
|
|
450
485
|
private wsConnect;
|
|
486
|
+
private wsSendContextAfterReconnection;
|
|
451
487
|
private wsSendMessage;
|
|
452
488
|
/**
|
|
453
489
|
* State management
|
|
@@ -493,6 +529,24 @@ export declare type SchematicOptions = {
|
|
|
493
529
|
useWebSocket?: boolean;
|
|
494
530
|
/** Optionally provide a custom WebSocket URL */
|
|
495
531
|
webSocketUrl?: string;
|
|
532
|
+
/** WebSocket connection timeout in milliseconds (default: 10000) */
|
|
533
|
+
webSocketConnectionTimeout?: number;
|
|
534
|
+
/** Enable automatic reconnection on WebSocket disconnect (default: true) */
|
|
535
|
+
webSocketReconnect?: boolean;
|
|
536
|
+
/** Maximum number of reconnection attempts (default: 7, set to Infinity for unlimited) */
|
|
537
|
+
webSocketMaxReconnectAttempts?: number;
|
|
538
|
+
/** Initial retry delay in milliseconds for exponential backoff (default: 1000) */
|
|
539
|
+
webSocketInitialRetryDelay?: number;
|
|
540
|
+
/** Maximum retry delay in milliseconds for exponential backoff (default: 30000) */
|
|
541
|
+
webSocketMaxRetryDelay?: number;
|
|
542
|
+
/** Maximum number of events to queue for retry when network is down (default: 100) */
|
|
543
|
+
maxEventQueueSize?: number;
|
|
544
|
+
/** Maximum number of retry attempts for failed events (default: 5) */
|
|
545
|
+
maxEventRetries?: number;
|
|
546
|
+
/** Initial retry delay in milliseconds for failed events (default: 1000) */
|
|
547
|
+
eventRetryInitialDelay?: number;
|
|
548
|
+
/** Maximum retry delay in milliseconds for failed events (default: 30000) */
|
|
549
|
+
eventRetryMaxDelay?: number;
|
|
496
550
|
};
|
|
497
551
|
|
|
498
552
|
/** Optional type for implementing custom client-side storage */
|
package/dist/schematic.esm.js
CHANGED
|
@@ -781,7 +781,7 @@ function contextString(context) {
|
|
|
781
781
|
}
|
|
782
782
|
|
|
783
783
|
// src/version.ts
|
|
784
|
-
var version = "1.2.
|
|
784
|
+
var version = "1.2.8";
|
|
785
785
|
|
|
786
786
|
// src/index.ts
|
|
787
787
|
var anonymousIdKey = "schematicId";
|
|
@@ -805,6 +805,23 @@ var Schematic = class {
|
|
|
805
805
|
checks = {};
|
|
806
806
|
featureUsageEventMap = {};
|
|
807
807
|
webSocketUrl = "wss://api.schematichq.com";
|
|
808
|
+
webSocketConnectionTimeout = 1e4;
|
|
809
|
+
webSocketReconnect = true;
|
|
810
|
+
webSocketMaxReconnectAttempts = 7;
|
|
811
|
+
webSocketInitialRetryDelay = 1e3;
|
|
812
|
+
webSocketMaxRetryDelay = 3e4;
|
|
813
|
+
wsReconnectAttempts = 0;
|
|
814
|
+
wsReconnectTimer = null;
|
|
815
|
+
wsIntentionalDisconnect = false;
|
|
816
|
+
maxEventQueueSize = 100;
|
|
817
|
+
// Prevent memory issues with very long network outages
|
|
818
|
+
maxEventRetries = 5;
|
|
819
|
+
// Maximum retry attempts for failed events
|
|
820
|
+
eventRetryInitialDelay = 1e3;
|
|
821
|
+
// Initial retry delay in ms
|
|
822
|
+
eventRetryMaxDelay = 3e4;
|
|
823
|
+
// Maximum retry delay in ms
|
|
824
|
+
retryTimer = null;
|
|
808
825
|
constructor(apiKey, options) {
|
|
809
826
|
this.apiKey = apiKey;
|
|
810
827
|
this.eventQueue = [];
|
|
@@ -848,11 +865,48 @@ var Schematic = class {
|
|
|
848
865
|
if (options?.webSocketUrl !== void 0) {
|
|
849
866
|
this.webSocketUrl = options.webSocketUrl;
|
|
850
867
|
}
|
|
868
|
+
if (options?.webSocketConnectionTimeout !== void 0) {
|
|
869
|
+
this.webSocketConnectionTimeout = options.webSocketConnectionTimeout;
|
|
870
|
+
}
|
|
871
|
+
if (options?.webSocketReconnect !== void 0) {
|
|
872
|
+
this.webSocketReconnect = options.webSocketReconnect;
|
|
873
|
+
}
|
|
874
|
+
if (options?.webSocketMaxReconnectAttempts !== void 0) {
|
|
875
|
+
this.webSocketMaxReconnectAttempts = options.webSocketMaxReconnectAttempts;
|
|
876
|
+
}
|
|
877
|
+
if (options?.webSocketInitialRetryDelay !== void 0) {
|
|
878
|
+
this.webSocketInitialRetryDelay = options.webSocketInitialRetryDelay;
|
|
879
|
+
}
|
|
880
|
+
if (options?.webSocketMaxRetryDelay !== void 0) {
|
|
881
|
+
this.webSocketMaxRetryDelay = options.webSocketMaxRetryDelay;
|
|
882
|
+
}
|
|
883
|
+
if (options?.maxEventQueueSize !== void 0) {
|
|
884
|
+
this.maxEventQueueSize = options.maxEventQueueSize;
|
|
885
|
+
}
|
|
886
|
+
if (options?.maxEventRetries !== void 0) {
|
|
887
|
+
this.maxEventRetries = options.maxEventRetries;
|
|
888
|
+
}
|
|
889
|
+
if (options?.eventRetryInitialDelay !== void 0) {
|
|
890
|
+
this.eventRetryInitialDelay = options.eventRetryInitialDelay;
|
|
891
|
+
}
|
|
892
|
+
if (options?.eventRetryMaxDelay !== void 0) {
|
|
893
|
+
this.eventRetryMaxDelay = options.eventRetryMaxDelay;
|
|
894
|
+
}
|
|
851
895
|
if (typeof window !== "undefined" && window?.addEventListener) {
|
|
852
896
|
window.addEventListener("beforeunload", () => {
|
|
853
897
|
this.flushEventQueue();
|
|
854
898
|
this.flushContextDependentEventQueue();
|
|
855
899
|
});
|
|
900
|
+
if (this.useWebSocket) {
|
|
901
|
+
window.addEventListener("offline", () => {
|
|
902
|
+
this.debug("Browser went offline, closing WebSocket connection");
|
|
903
|
+
this.handleNetworkOffline();
|
|
904
|
+
});
|
|
905
|
+
window.addEventListener("online", () => {
|
|
906
|
+
this.debug("Browser came online, attempting to reconnect WebSocket");
|
|
907
|
+
this.handleNetworkOnline();
|
|
908
|
+
});
|
|
909
|
+
}
|
|
856
910
|
}
|
|
857
911
|
if (this.offlineEnabled) {
|
|
858
912
|
this.debug(
|
|
@@ -1117,6 +1171,13 @@ var Schematic = class {
|
|
|
1117
1171
|
try {
|
|
1118
1172
|
this.setIsPending(true);
|
|
1119
1173
|
if (!this.conn) {
|
|
1174
|
+
if (this.wsReconnectTimer !== null) {
|
|
1175
|
+
this.debug(
|
|
1176
|
+
`Cancelling scheduled reconnection, connecting immediately`
|
|
1177
|
+
);
|
|
1178
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1179
|
+
this.wsReconnectTimer = null;
|
|
1180
|
+
}
|
|
1120
1181
|
this.conn = this.wsConnect();
|
|
1121
1182
|
}
|
|
1122
1183
|
const socket = await this.conn;
|
|
@@ -1246,11 +1307,53 @@ var Schematic = class {
|
|
|
1246
1307
|
}
|
|
1247
1308
|
}
|
|
1248
1309
|
};
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1310
|
+
startRetryTimer = () => {
|
|
1311
|
+
if (this.retryTimer !== null) {
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
this.retryTimer = setInterval(() => {
|
|
1315
|
+
this.flushEventQueue().catch((error) => {
|
|
1316
|
+
this.debug("Error in retry timer flush:", error);
|
|
1317
|
+
});
|
|
1318
|
+
if (this.eventQueue.length === 0) {
|
|
1319
|
+
this.stopRetryTimer();
|
|
1320
|
+
}
|
|
1321
|
+
}, 5e3);
|
|
1322
|
+
this.debug("Started retry timer");
|
|
1323
|
+
};
|
|
1324
|
+
stopRetryTimer = () => {
|
|
1325
|
+
if (this.retryTimer !== null) {
|
|
1326
|
+
clearInterval(this.retryTimer);
|
|
1327
|
+
this.retryTimer = null;
|
|
1328
|
+
this.debug("Stopped retry timer");
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
flushEventQueue = async () => {
|
|
1332
|
+
if (this.eventQueue.length === 0) {
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const now = Date.now();
|
|
1336
|
+
const readyEvents = [];
|
|
1337
|
+
const notReadyEvents = [];
|
|
1338
|
+
for (const event of this.eventQueue) {
|
|
1339
|
+
if (event.next_retry_at === void 0 || event.next_retry_at <= now) {
|
|
1340
|
+
readyEvents.push(event);
|
|
1341
|
+
} else {
|
|
1342
|
+
notReadyEvents.push(event);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (readyEvents.length === 0) {
|
|
1346
|
+
this.debug(`No events ready for retry yet (${notReadyEvents.length} still in backoff)`);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
this.debug(`Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`);
|
|
1350
|
+
this.eventQueue = notReadyEvents;
|
|
1351
|
+
for (const event of readyEvents) {
|
|
1352
|
+
try {
|
|
1353
|
+
await this.sendEvent(event);
|
|
1354
|
+
this.debug(`Queued event sent successfully:`, event.type);
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
this.debug(`Failed to send queued event:`, error);
|
|
1254
1357
|
}
|
|
1255
1358
|
}
|
|
1256
1359
|
};
|
|
@@ -1298,12 +1401,37 @@ var Schematic = class {
|
|
|
1298
1401
|
},
|
|
1299
1402
|
body: payload
|
|
1300
1403
|
});
|
|
1404
|
+
if (!response.ok) {
|
|
1405
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1406
|
+
}
|
|
1301
1407
|
this.debug(`event sent:`, {
|
|
1302
1408
|
status: response.status,
|
|
1303
1409
|
statusText: response.statusText
|
|
1304
1410
|
});
|
|
1305
1411
|
} catch (error) {
|
|
1306
|
-
|
|
1412
|
+
const retryCount = (event.retry_count ?? 0) + 1;
|
|
1413
|
+
if (retryCount <= this.maxEventRetries) {
|
|
1414
|
+
this.debug(`Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`, error);
|
|
1415
|
+
const baseDelay = this.eventRetryInitialDelay * Math.pow(2, retryCount - 1);
|
|
1416
|
+
const jitterDelay = Math.min(baseDelay, this.eventRetryMaxDelay);
|
|
1417
|
+
const nextRetryAt = Date.now() + jitterDelay;
|
|
1418
|
+
const retryEvent = {
|
|
1419
|
+
...event,
|
|
1420
|
+
retry_count: retryCount,
|
|
1421
|
+
next_retry_at: nextRetryAt
|
|
1422
|
+
};
|
|
1423
|
+
if (this.eventQueue.length < this.maxEventQueueSize) {
|
|
1424
|
+
this.eventQueue.push(retryEvent);
|
|
1425
|
+
this.debug(`Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`);
|
|
1426
|
+
} else {
|
|
1427
|
+
this.debug(`Event queue full (${this.maxEventQueueSize}), dropping oldest event`);
|
|
1428
|
+
this.eventQueue.shift();
|
|
1429
|
+
this.eventQueue.push(retryEvent);
|
|
1430
|
+
}
|
|
1431
|
+
this.startRetryTimer();
|
|
1432
|
+
} else {
|
|
1433
|
+
this.debug(`Event failed permanently after ${this.maxEventRetries} attempts, dropping:`, error);
|
|
1434
|
+
}
|
|
1307
1435
|
}
|
|
1308
1436
|
return Promise.resolve();
|
|
1309
1437
|
};
|
|
@@ -1323,6 +1451,12 @@ var Schematic = class {
|
|
|
1323
1451
|
this.debug("cleanup: skipped (offline mode)");
|
|
1324
1452
|
return Promise.resolve();
|
|
1325
1453
|
}
|
|
1454
|
+
this.wsIntentionalDisconnect = true;
|
|
1455
|
+
if (this.wsReconnectTimer !== null) {
|
|
1456
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1457
|
+
this.wsReconnectTimer = null;
|
|
1458
|
+
}
|
|
1459
|
+
this.stopRetryTimer();
|
|
1326
1460
|
if (this.conn) {
|
|
1327
1461
|
try {
|
|
1328
1462
|
const socket = await this.conn;
|
|
@@ -1334,6 +1468,100 @@ var Schematic = class {
|
|
|
1334
1468
|
}
|
|
1335
1469
|
}
|
|
1336
1470
|
};
|
|
1471
|
+
/**
|
|
1472
|
+
* Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
|
|
1473
|
+
* This helps prevent dogpiling when the server recovers from an outage.
|
|
1474
|
+
*/
|
|
1475
|
+
calculateReconnectDelay = () => {
|
|
1476
|
+
const exponentialDelay = this.webSocketInitialRetryDelay * Math.pow(2, this.wsReconnectAttempts);
|
|
1477
|
+
const cappedDelay = Math.min(exponentialDelay, this.webSocketMaxRetryDelay);
|
|
1478
|
+
const jitter = Math.random() * cappedDelay * 0.5;
|
|
1479
|
+
const totalDelay = cappedDelay + jitter;
|
|
1480
|
+
this.debug(
|
|
1481
|
+
`Reconnect delay calculated: ${totalDelay.toFixed(0)}ms (attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts})`
|
|
1482
|
+
);
|
|
1483
|
+
return totalDelay;
|
|
1484
|
+
};
|
|
1485
|
+
/**
|
|
1486
|
+
* Handle browser going offline
|
|
1487
|
+
*/
|
|
1488
|
+
handleNetworkOffline = async () => {
|
|
1489
|
+
if (this.conn !== null) {
|
|
1490
|
+
try {
|
|
1491
|
+
const socket = await this.conn;
|
|
1492
|
+
socket.close();
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
this.debug("Error closing connection on offline:", error);
|
|
1495
|
+
}
|
|
1496
|
+
this.conn = null;
|
|
1497
|
+
}
|
|
1498
|
+
if (this.wsReconnectTimer !== null) {
|
|
1499
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1500
|
+
this.wsReconnectTimer = null;
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
/**
|
|
1504
|
+
* Handle browser coming back online
|
|
1505
|
+
*/
|
|
1506
|
+
handleNetworkOnline = () => {
|
|
1507
|
+
this.debug("Network online, attempting reconnection and flushing queued events");
|
|
1508
|
+
this.wsReconnectAttempts = 0;
|
|
1509
|
+
if (this.wsReconnectTimer !== null) {
|
|
1510
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1511
|
+
this.wsReconnectTimer = null;
|
|
1512
|
+
}
|
|
1513
|
+
this.flushEventQueue().catch((error) => {
|
|
1514
|
+
this.debug("Error flushing event queue on network online:", error);
|
|
1515
|
+
});
|
|
1516
|
+
this.attemptReconnect();
|
|
1517
|
+
};
|
|
1518
|
+
/**
|
|
1519
|
+
* Attempt to reconnect the WebSocket connection with exponential backoff.
|
|
1520
|
+
* Called automatically when the connection closes unexpectedly.
|
|
1521
|
+
*/
|
|
1522
|
+
attemptReconnect = () => {
|
|
1523
|
+
if (this.wsReconnectAttempts >= this.webSocketMaxReconnectAttempts) {
|
|
1524
|
+
this.debug(
|
|
1525
|
+
`Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`
|
|
1526
|
+
);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (this.wsReconnectTimer !== null) {
|
|
1530
|
+
clearTimeout(this.wsReconnectTimer);
|
|
1531
|
+
}
|
|
1532
|
+
const delay = this.calculateReconnectDelay();
|
|
1533
|
+
this.debug(
|
|
1534
|
+
`Scheduling reconnection attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts} in ${delay.toFixed(0)}ms`
|
|
1535
|
+
);
|
|
1536
|
+
this.wsReconnectTimer = setTimeout(async () => {
|
|
1537
|
+
this.wsReconnectTimer = null;
|
|
1538
|
+
this.wsReconnectAttempts++;
|
|
1539
|
+
this.debug(
|
|
1540
|
+
`Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
|
|
1541
|
+
);
|
|
1542
|
+
try {
|
|
1543
|
+
this.conn = this.wsConnect();
|
|
1544
|
+
const socket = await this.conn;
|
|
1545
|
+
this.debug(`Reconnection context check:`, {
|
|
1546
|
+
hasCompany: this.context.company !== void 0,
|
|
1547
|
+
hasUser: this.context.user !== void 0,
|
|
1548
|
+
context: this.context
|
|
1549
|
+
});
|
|
1550
|
+
if (this.context.company !== void 0 || this.context.user !== void 0) {
|
|
1551
|
+
this.debug(`Reconnected, force re-sending context`);
|
|
1552
|
+
await this.wsSendContextAfterReconnection(socket, this.context);
|
|
1553
|
+
} else {
|
|
1554
|
+
this.debug(`No context to re-send after reconnection - websocket ready for new context`);
|
|
1555
|
+
}
|
|
1556
|
+
this.flushEventQueue().catch((error) => {
|
|
1557
|
+
this.debug("Error flushing event queue after websocket reconnection:", error);
|
|
1558
|
+
});
|
|
1559
|
+
this.debug(`Reconnection successful`);
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
this.debug(`Reconnection attempt failed:`, error);
|
|
1562
|
+
}
|
|
1563
|
+
}, delay);
|
|
1564
|
+
};
|
|
1337
1565
|
// Open a websocket connection
|
|
1338
1566
|
wsConnect = () => {
|
|
1339
1567
|
if (this.isOffline()) {
|
|
@@ -1346,20 +1574,103 @@ var Schematic = class {
|
|
|
1346
1574
|
const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
|
|
1347
1575
|
this.debug(`connecting to WebSocket:`, wsUrl);
|
|
1348
1576
|
const webSocket = new WebSocket(wsUrl);
|
|
1577
|
+
let timeoutId = null;
|
|
1578
|
+
let isResolved = false;
|
|
1579
|
+
timeoutId = setTimeout(() => {
|
|
1580
|
+
if (!isResolved) {
|
|
1581
|
+
this.debug(
|
|
1582
|
+
`WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`
|
|
1583
|
+
);
|
|
1584
|
+
webSocket.close();
|
|
1585
|
+
reject(new Error("WebSocket connection timeout"));
|
|
1586
|
+
}
|
|
1587
|
+
}, this.webSocketConnectionTimeout);
|
|
1349
1588
|
webSocket.onopen = () => {
|
|
1589
|
+
isResolved = true;
|
|
1590
|
+
if (timeoutId !== null) {
|
|
1591
|
+
clearTimeout(timeoutId);
|
|
1592
|
+
}
|
|
1593
|
+
this.wsReconnectAttempts = 0;
|
|
1594
|
+
this.wsIntentionalDisconnect = false;
|
|
1350
1595
|
this.debug(`WebSocket connection opened`);
|
|
1351
1596
|
resolve(webSocket);
|
|
1352
1597
|
};
|
|
1353
1598
|
webSocket.onerror = (error) => {
|
|
1599
|
+
isResolved = true;
|
|
1600
|
+
if (timeoutId !== null) {
|
|
1601
|
+
clearTimeout(timeoutId);
|
|
1602
|
+
}
|
|
1354
1603
|
this.debug(`WebSocket connection error:`, error);
|
|
1355
1604
|
reject(error);
|
|
1356
1605
|
};
|
|
1357
1606
|
webSocket.onclose = () => {
|
|
1607
|
+
isResolved = true;
|
|
1608
|
+
if (timeoutId !== null) {
|
|
1609
|
+
clearTimeout(timeoutId);
|
|
1610
|
+
}
|
|
1358
1611
|
this.debug(`WebSocket connection closed`);
|
|
1359
1612
|
this.conn = null;
|
|
1613
|
+
if (!this.wsIntentionalDisconnect && this.webSocketReconnect) {
|
|
1614
|
+
this.attemptReconnect();
|
|
1615
|
+
}
|
|
1360
1616
|
};
|
|
1361
1617
|
});
|
|
1362
1618
|
};
|
|
1619
|
+
// Send a message on the websocket after reconnection, forcing the send even if context appears unchanged
|
|
1620
|
+
// because the server has lost all state and needs the initial context
|
|
1621
|
+
wsSendContextAfterReconnection = (socket, context) => {
|
|
1622
|
+
if (this.isOffline()) {
|
|
1623
|
+
this.debug("wsSendContextAfterReconnection: skipped (offline mode)");
|
|
1624
|
+
this.setIsPending(false);
|
|
1625
|
+
return Promise.resolve();
|
|
1626
|
+
}
|
|
1627
|
+
return new Promise((resolve) => {
|
|
1628
|
+
this.debug(`WebSocket force sending context after reconnection:`, context);
|
|
1629
|
+
this.context = context;
|
|
1630
|
+
const sendMessage = () => {
|
|
1631
|
+
let resolved = false;
|
|
1632
|
+
const messageHandler = (event) => {
|
|
1633
|
+
const message = JSON.parse(event.data);
|
|
1634
|
+
this.debug(`WebSocket message received after reconnection:`, message);
|
|
1635
|
+
if (!(contextString(context) in this.checks)) {
|
|
1636
|
+
this.checks[contextString(context)] = {};
|
|
1637
|
+
}
|
|
1638
|
+
(message.flags ?? []).forEach((flag) => {
|
|
1639
|
+
const flagCheck = CheckFlagReturnFromJSON(flag);
|
|
1640
|
+
const contextStr = contextString(context);
|
|
1641
|
+
if (this.checks[contextStr] === void 0) {
|
|
1642
|
+
this.checks[contextStr] = {};
|
|
1643
|
+
}
|
|
1644
|
+
this.checks[contextStr][flagCheck.flag] = flagCheck;
|
|
1645
|
+
});
|
|
1646
|
+
this.useWebSocket = true;
|
|
1647
|
+
socket.removeEventListener("message", messageHandler);
|
|
1648
|
+
if (!resolved) {
|
|
1649
|
+
resolved = true;
|
|
1650
|
+
resolve(this.setIsPending(false));
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
socket.addEventListener("message", messageHandler);
|
|
1654
|
+
const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
|
|
1655
|
+
const messagePayload = {
|
|
1656
|
+
apiKey: this.apiKey,
|
|
1657
|
+
clientVersion,
|
|
1658
|
+
data: context
|
|
1659
|
+
};
|
|
1660
|
+
this.debug(`WebSocket sending forced message after reconnection:`, messagePayload);
|
|
1661
|
+
socket.send(JSON.stringify(messagePayload));
|
|
1662
|
+
};
|
|
1663
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
1664
|
+
this.debug(`WebSocket already open, sending forced message after reconnection`);
|
|
1665
|
+
sendMessage();
|
|
1666
|
+
} else {
|
|
1667
|
+
socket.addEventListener("open", () => {
|
|
1668
|
+
this.debug(`WebSocket opened, sending forced message after reconnection`);
|
|
1669
|
+
sendMessage();
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
};
|
|
1363
1674
|
// Send a message on the websocket indicating interest in a particular evaluation context
|
|
1364
1675
|
// and wait for the initial set of flag values to be returned
|
|
1365
1676
|
wsSendMessage = (socket, context) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schematichq/schematic-js",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"main": "dist/schematic.cjs.js",
|
|
5
5
|
"module": "dist/schematic.esm.js",
|
|
6
6
|
"types": "dist/schematic.d.ts",
|
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
"lint": "eslint src --report-unused-disable-directives --fix",
|
|
28
28
|
"openapi": "rm -rf src/types/api/ && npx openapi-generator-cli generate -c openapi-config.yaml --global-property models=\"EventBody:EventBodyFlagCheck:EventBodyIdentify:EventBodyIdentifyCompany:EventBodyTrack:CheckFlagResponse:CheckFlagResponseData:CheckFlagsResponse:CheckFlagsResponseData\",supportingFiles=runtime.ts && prettier --write \"src/types/api/**/*.{ts,tsx}\"",
|
|
29
29
|
"prepare": "husky",
|
|
30
|
-
"test": "
|
|
31
|
-
"test:reactnative": "
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:reactnative": "vitest run --config vitest.config.reactnative.ts",
|
|
32
|
+
"test:watch": "vitest",
|
|
32
33
|
"tsc": "npx tsc"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
@@ -36,25 +37,21 @@
|
|
|
36
37
|
"uuid": "^13.0.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@eslint/js": "^9.
|
|
40
|
-
"@microsoft/api-extractor": "^7.
|
|
41
|
-
"@openapitools/openapi-generator-cli": "^2.
|
|
42
|
-
"@
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"globals": "^16.0.0",
|
|
40
|
+
"@eslint/js": "^9.39.1",
|
|
41
|
+
"@microsoft/api-extractor": "^7.55.0",
|
|
42
|
+
"@openapitools/openapi-generator-cli": "^2.25.1",
|
|
43
|
+
"@vitest/browser": "^4.0.8",
|
|
44
|
+
"esbuild": "^0.27.0",
|
|
45
|
+
"eslint": "^9.39.1",
|
|
46
|
+
"globals": "^16.5.0",
|
|
47
|
+
"happy-dom": "^20.0.10",
|
|
48
48
|
"husky": "^9.1.7",
|
|
49
|
-
"
|
|
50
|
-
"jest-environment-jsdom": "^30.0.0",
|
|
51
|
-
"jest-esbuild": "^0.4.0",
|
|
52
|
-
"jest-fetch-mock": "^3.0.3",
|
|
49
|
+
"jsdom": "^27.2.0",
|
|
53
50
|
"mock-socket": "^9.3.1",
|
|
54
51
|
"prettier": "^3.6.2",
|
|
55
|
-
"
|
|
56
|
-
"typescript": "^
|
|
57
|
-
"
|
|
52
|
+
"typescript": "^5.9.3",
|
|
53
|
+
"typescript-eslint": "^8.47.0",
|
|
54
|
+
"vitest": "^4.0.8"
|
|
58
55
|
},
|
|
59
56
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
|
60
57
|
}
|