@splitlab/node 0.3.0 → 0.5.0

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/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";var u=Object.defineProperty;var d=Object.getOwnPropertyDescriptor;var y=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var R=(r,e)=>{for(var i in e)u(r,i,{get:e[i],enumerable:!0})},b=(r,e,i,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of y(e))!C.call(r,t)&&t!==i&&u(r,t,{get:()=>e[t],enumerable:!(n=d(e,t))||n.enumerable});return r};var T=r=>b(u({},"__esModule",{value:!0}),r);var I={};R(I,{SplitLabServer:()=>g,hashToFloat:()=>h.hashToFloat,murmurhash3:()=>h.murmurhash3});module.exports=T(I);var h=require("@splitlab/core"),s=require("@splitlab/core"),g=class{constructor(e){this.serverConfig=null;this.eventQueue=[];this.flushTimer=null;this.configRefreshTimer=null;this.lastEtag=null;this.ready=!1;this.apiKey=e.apiKey,this.baseUrl=e.baseUrl.replace(/\/$/,""),this.ingestUrl=(e.ingestUrl||e.baseUrl).replace(/\/$/,""),this.configRefreshInterval=e.configRefreshInterval??3e4,this.flushInterval=e.flushInterval??1e4,this.flushSize=e.flushSize??100,this.onConfigUpdate=e.onConfigUpdate??null,this.environment=e.environment??"production"}async initialize(){let{config:e,etag:i}=await this.fetchConfig();this.serverConfig=e,this.lastEtag=i,this.configRefreshTimer=setInterval(()=>{this.refresh().catch(()=>{})},this.configRefreshInterval),this.flushTimer=setInterval(()=>{this.flush().catch(()=>{})},this.flushInterval),this.ready=!0}isReady(){return this.ready}async destroy(){this.configRefreshTimer!==null&&(clearInterval(this.configRefreshTimer),this.configRefreshTimer=null),this.flushTimer!==null&&(clearInterval(this.flushTimer),this.flushTimer=null),await this.flush(),this.ready=!1}getVariant(e,i,n){if(!this.serverConfig)return null;let t=this.serverConfig.experiments.find(a=>a.key===e);if(!t)return null;let l=this.serverConfig.segments||[];if(t.targeting_rules&&!(0,s.evaluateRules)(t.targeting_rules,n||{},l))return null;let o=(0,s.murmurhash3)(t.key+":"+i);if(o%1e4/100>=t.traffic_percentage)return null;let f=t.variants.reduce((a,m)=>a+m.weight,0),p=o%f,c=0;for(let a of t.variants)if(c+=a.weight,p<c)return a.key;return t.variants[t.variants.length-1].key}isFeatureEnabled(e,i,n){if(!this.serverConfig)return!1;let t=this.serverConfig.flags.find(f=>f.key===e);if(!t)return!1;let l=this.serverConfig.segments||[];return t.rules&&!(0,s.evaluateRules)(t.rules,n||{},l)?!1:(0,s.murmurhash3)(t.key+":"+i)%100<t.rollout_percentage}evaluateAll(e,i){return this.serverConfig?(0,s.localEvaluate)(this.serverConfig,e,i||{}):{experiments:{},flags:{}}}track(e,i,n){this.eventQueue.push({distinct_id:e,event_name:i,properties:n,timestamp:new Date().toISOString()}),this.eventQueue.length>=this.flushSize&&this.flush().catch(()=>{})}async flush(){if(this.eventQueue.length===0)return;let e=this.eventQueue;this.eventQueue=[];try{(await fetch(`${this.ingestUrl}/ingest/batch`,{method:"POST",headers:{"Content-Type":"application/json","X-API-Key":this.apiKey},body:JSON.stringify({events:e})})).ok||(this.eventQueue=e.concat(this.eventQueue))}catch{this.eventQueue=e.concat(this.eventQueue)}}async refresh(){try{let{config:e,etag:i,notModified:n}=await this.fetchConfig();if(n)return;this.serverConfig=e,this.lastEtag=i,this.onConfigUpdate&&this.onConfigUpdate()}catch{}}async fetchConfig(){let e={"Content-Type":"application/json"};this.lastEtag&&(e["If-None-Match"]=this.lastEtag);let i=this.environment!=="production"?`&env=${encodeURIComponent(this.environment)}`:"",n=await fetch(`${this.baseUrl}/api/sdk/config?key=${encodeURIComponent(this.apiKey)}${i}`,{method:"GET",headers:e});if(n.status===304)return{config:this.serverConfig,etag:this.lastEtag,notModified:!0};if(!n.ok){let o=await n.text().catch(()=>"");throw new Error(`SplitLab API error ${n.status}: ${o}`)}let t=n.headers.get("etag");return{config:await n.json(),etag:t}}};0&&(module.exports={SplitLabServer,hashToFloat,murmurhash3});
1
+ "use strict";var m=Object.defineProperty;var R=Object.getOwnPropertyDescriptor;var S=Object.getOwnPropertyNames;var b=Object.prototype.hasOwnProperty;var k=(n,e)=>{for(var s in e)m(n,s,{get:e[s],enumerable:!0})},x=(n,e,s,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of S(e))!b.call(n,r)&&r!==s&&m(n,r,{get:()=>e[r],enumerable:!(t=R(e,r))||t.enumerable});return n};var w=n=>x(m({},"__esModule",{value:!0}),n);var D={};k(D,{RequestContext:()=>p,SplitLabServer:()=>_,hashToFloat:()=>d.hashToFloat,murmurhash3:()=>d.murmurhash3});module.exports=w(D);var d=require("@splitlab/core"),l=require("@splitlab/core"),v=class v{constructor(e){this.serverConfig=null;this.eventQueue=[];this.flushTimer=null;this.configRefreshTimer=null;this.lastEtag=null;this.ready=!1;this.trackedExposures=new Set;this.apiKey=e.apiKey,this.baseUrl=e.baseUrl.replace(/\/$/,""),this.ingestUrl=(e.ingestUrl||e.baseUrl).replace(/\/$/,""),this.configRefreshInterval=e.configRefreshInterval??3e4,this.flushInterval=e.flushInterval??1e4,this.flushSize=e.flushSize??100,this.onConfigUpdate=e.onConfigUpdate??null,this.environment=e.environment??"production"}async initialize(){let{config:e,etag:s}=await this.fetchConfig();this.serverConfig=e,this.lastEtag=s,this.configRefreshTimer=setInterval(()=>{this.refresh().catch(()=>{})},this.configRefreshInterval),this.flushTimer=setInterval(()=>{this.flush().catch(()=>{})},this.flushInterval),this.ready=!0}isReady(){return this.ready}async destroy(){this.configRefreshTimer!==null&&(clearInterval(this.configRefreshTimer),this.configRefreshTimer=null),this.flushTimer!==null&&(clearInterval(this.flushTimer),this.flushTimer=null),await this.flush(),this.ready=!1}getVariant(e,s,t){if(!this.serverConfig)return null;let r=this.serverConfig.experiments.find(o=>o.key===e);if(!r)return null;let i=this.serverConfig.segments||[];if(r.targeting_rules&&!(0,l.evaluateRules)(r.targeting_rules,t||{},i))return null;let a=(0,l.murmurhash3)(r.key+":"+s);if(a%1e4/100>=r.traffic_percentage)return null;let h=r.variants.reduce((o,E)=>o+E.weight,0),f=a%h,c=0;for(let o of r.variants)if(c+=o.weight,f<c)return this.trackExposure(e,o.key,s),o.key;let g=r.variants[r.variants.length-1].key;return this.trackExposure(e,g,s),g}isFeatureEnabled(e,s,t){if(!this.serverConfig)return!1;let r=this.serverConfig.flags.find(h=>h.key===e);if(!r)return!1;let i=this.serverConfig.segments||[];return r.rules&&!(0,l.evaluateRules)(r.rules,t||{},i)?!1:(0,l.murmurhash3)(r.key+":"+s)%100<r.rollout_percentage}evaluateAll(e,s){return this.serverConfig?(0,l.localEvaluate)(this.serverConfig,e,s||{}):{experiments:{},flags:{}}}getConfigSnapshot(){return{serverConfig:this.serverConfig,lastEtag:this.lastEtag}}withContext(e){return new p(this,e)}track(e,s,t){this.eventQueue.push({distinct_id:e,event_name:s,properties:t,timestamp:new Date().toISOString()}),this.eventQueue.length>=this.flushSize&&this.flush().catch(()=>{})}trackExposure(e,s,t){let r=`${e}:${t}`;this.trackedExposures.has(r)||(this.trackedExposures.size>=v.MAX_EXPOSURE_CACHE&&this.trackedExposures.clear(),this.trackedExposures.add(r),this.track(t,"$exposure",{experiment_key:e,variant_key:s}))}async flush(){if(this.eventQueue.length===0)return;let e=this.eventQueue;this.eventQueue=[];try{(await fetch(`${this.ingestUrl}/ingest/batch`,{method:"POST",headers:{"Content-Type":"application/json","X-API-Key":this.apiKey},body:JSON.stringify({events:e})})).ok||(this.eventQueue=e.concat(this.eventQueue))}catch{this.eventQueue=e.concat(this.eventQueue)}}async refresh(){try{let{config:e,etag:s,notModified:t}=await this.fetchConfig();if(t)return;this.serverConfig=e,this.lastEtag=s,this.onConfigUpdate&&this.onConfigUpdate()}catch{}}async fetchConfig(){let e={"Content-Type":"application/json"};this.lastEtag&&(e["If-None-Match"]=this.lastEtag);let s=this.environment!=="production"?`&env=${encodeURIComponent(this.environment)}`:"",t=await fetch(`${this.baseUrl}/api/sdk/config?key=${encodeURIComponent(this.apiKey)}${s}`,{method:"GET",headers:e});if(t.status===304)return{config:this.serverConfig,etag:this.lastEtag,notModified:!0};if(!t.ok){let a=await t.text().catch(()=>"");throw new Error(`SplitLab API error ${t.status}: ${a}`)}let r=t.headers.get("etag");return{config:await t.json(),etag:r}}};v.MAX_EXPOSURE_CACHE=5e4;var _=v,C="_sl_did",I="_sl_sid",U=63072e3,T=1800,P=["utm_source","utm_medium","utm_campaign","utm_term","utm_content"],p=class{constructor(e,s){this.server=e;let t=$(s.headers),r=s.cookies||A(t.cookie||""),i=(0,l.parseUserAgent)(t["user-agent"]||void 0),a=O(s.url,t.host),u=r[C]||null;this._deviceId=u||y(),this._newDeviceId=!u;let h=r[I]||null;this._sessionId=h||y(),this._newSessionId=!h,this.props={},i.browser&&(this.props.browser=i.browser),i.browser_version&&(this.props.browser_version=i.browser_version),i.os&&(this.props.os=i.os),i.os_version&&(this.props.os_version=i.os_version),i.device_type&&(this.props.device_type=i.device_type),t["user-agent"]&&(this.props.user_agent=t["user-agent"]),a.pathname&&(this.props.pathname=a.pathname),a.hostname&&(this.props.hostname=a.hostname);let f=t.referer||t.referrer||null;f&&(this.props.referrer=f);let c=t["accept-language"];if(c&&(this.props.language=c.split(",")[0].split(";")[0].trim()),a.searchParams)for(let g of P){let o=a.searchParams.get(g);o&&(this.props[g]=o)}this.props.device_id=this._deviceId,this._sessionId&&(this.props.session_id=this._sessionId)}get deviceId(){return this._deviceId}get sessionId(){return this._sessionId}getVariant(e,s){return this.server.getVariant(e,s||this._deviceId)}isFeatureEnabled(e,s,t){return this.server.isFeatureEnabled(e,s||this._deviceId,t)}track(e,s,t){this.server.track(t||this._deviceId,e,{...this.props,...s})}getResponseCookies(){let e=[];return this._newDeviceId&&e.push(`${C}=${encodeURIComponent(this._deviceId)}; Path=/; Max-Age=${U}; SameSite=Lax`),this._sessionId&&e.push(`${I}=${encodeURIComponent(this._sessionId)}; Path=/; Max-Age=${T}; SameSite=Lax`),e}getBootstrapData(e,s){let t=e||this._deviceId,r=this.server.evaluateAll(t,s),{serverConfig:i,lastEtag:a}=this.server.getConfigSnapshot();return{evalResult:r,serverConfig:i||{experiments:[],flags:[]},deviceId:this._deviceId,sessionId:this._sessionId,etag:a,generatedAt:new Date().toISOString()}}};function y(){let n=Date.now().toString(36),e=Math.random().toString(36).substring(2,10);return`${n}-${e}`}function $(n){let e={};if(n instanceof Headers)n.forEach((s,t)=>{e[t]=s});else for(let[s,t]of Object.entries(n))t!==void 0&&(e[s.toLowerCase()]=Array.isArray(t)?t[0]:t);return e}function A(n){let e={};if(!n)return e;for(let s of n.split(";")){let t=s.indexOf("=");if(t===-1)continue;let r=s.substring(0,t).trim(),i=s.substring(t+1).trim();try{e[r]=decodeURIComponent(i)}catch{e[r]=i}}return e}function O(n,e){if(!n)return{pathname:null,hostname:null,searchParams:null};try{let s=e?`http://${e}`:"http://localhost",t=new URL(n,s);return{pathname:t.pathname,hostname:t.hostname!=="localhost"?t.hostname:e?.split(":")[0]||null,searchParams:t.searchParams}}catch{return{pathname:n.split("?")[0]||null,hostname:null,searchParams:null}}}0&&(module.exports={RequestContext,SplitLabServer,hashToFloat,murmurhash3});
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { EvalResult } from '@splitlab/core';
2
- export { EvalResult, ExclusionGroupConfig, ExperimentConfig, FlagConfig, SegmentConfig, ServerConfig, TargetingCondition, TargetingGroup, TargetingRules, TrackEvent, Variant, hashToFloat, murmurhash3 } from '@splitlab/core';
1
+ import { EvalResult, ServerConfig, BootstrapData } from '@splitlab/core';
2
+ export { BootstrapData, EvalResult, ExclusionGroupConfig, ExperimentConfig, FlagConfig, SegmentConfig, ServerConfig, TargetingCondition, TargetingGroup, TargetingRules, TrackEvent, UAResult, Variant, hashToFloat, murmurhash3 } from '@splitlab/core';
3
3
 
4
4
  interface SplitLabServerConfig {
5
5
  apiKey: string;
@@ -31,6 +31,8 @@ declare class SplitLabServer {
31
31
  private configRefreshTimer;
32
32
  private lastEtag;
33
33
  private ready;
34
+ private trackedExposures;
35
+ private static readonly MAX_EXPOSURE_CACHE;
34
36
  constructor(config: SplitLabServerConfig);
35
37
  initialize(): Promise<void>;
36
38
  isReady(): boolean;
@@ -38,10 +40,60 @@ declare class SplitLabServer {
38
40
  getVariant(experimentKey: string, distinctId: string, attributes?: Record<string, any>): string | null;
39
41
  isFeatureEnabled(flagKey: string, distinctId: string, attributes?: Record<string, any>): boolean;
40
42
  evaluateAll(distinctId: string, attributes?: Record<string, any>): EvalResult;
43
+ getConfigSnapshot(): {
44
+ serverConfig: ServerConfig | null;
45
+ lastEtag: string | null;
46
+ };
47
+ /**
48
+ * Create a request-scoped context that enriches events with server-side
49
+ * properties parsed from the HTTP request (UA, URL, cookies, UTM, etc.).
50
+ *
51
+ * Reads `_sl_did` (device ID) and `_sl_sid` (session ID) cookies.
52
+ * If device ID is missing, generates one — call `ctx.getResponseCookies()`
53
+ * to get Set-Cookie headers for the response.
54
+ */
55
+ withContext(req: IncomingRequest): RequestContext;
41
56
  track(distinctId: string, eventName: string, properties?: Record<string, any>): void;
57
+ private trackExposure;
42
58
  flush(): Promise<void>;
43
59
  refresh(): Promise<void>;
44
60
  private fetchConfig;
45
61
  }
62
+ /** Minimal request shape — works with Express, Node http, or plain objects. */
63
+ interface IncomingRequest {
64
+ headers: Record<string, string | string[] | undefined> | Headers;
65
+ url?: string;
66
+ cookies?: Record<string, string>;
67
+ }
68
+ declare class RequestContext {
69
+ private server;
70
+ private props;
71
+ private _deviceId;
72
+ private _sessionId;
73
+ private _newDeviceId;
74
+ private _newSessionId;
75
+ constructor(server: SplitLabServer, req: IncomingRequest);
76
+ /** The device ID (from cookie or newly generated). */
77
+ get deviceId(): string;
78
+ /** The session ID (from cookie or newly generated). */
79
+ get sessionId(): string | null;
80
+ /** Evaluate an experiment variant with request context enrichment. */
81
+ getVariant(experimentKey: string, distinctId?: string): string | null;
82
+ /** Evaluate a feature flag. */
83
+ isFeatureEnabled(flagKey: string, distinctId?: string, attributes?: Record<string, any>): boolean;
84
+ /** Track an event enriched with request context properties. */
85
+ track(eventName: string, properties?: Record<string, any>, distinctId?: string): void;
86
+ /**
87
+ * Returns Set-Cookie headers to include in the HTTP response.
88
+ * Sets `_sl_did` (2yr) if new, always refreshes `_sl_sid` (30min sliding).
89
+ */
90
+ getResponseCookies(): string[];
91
+ /**
92
+ * Generate bootstrap data for client-side hydration.
93
+ * Evaluates all experiments/flags (pure, no exposure events) and packages
94
+ * the result with device ID, session ID, server config, and ETag.
95
+ */
96
+ getBootstrapData(distinctId?: string, attributes?: Record<string, any>): BootstrapData;
97
+ }
46
98
 
47
- export { SplitLabServer, type SplitLabServerConfig };
99
+ export { type IncomingRequest, RequestContext, SplitLabServer, type SplitLabServerConfig };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { EvalResult } from '@splitlab/core';
2
- export { EvalResult, ExclusionGroupConfig, ExperimentConfig, FlagConfig, SegmentConfig, ServerConfig, TargetingCondition, TargetingGroup, TargetingRules, TrackEvent, Variant, hashToFloat, murmurhash3 } from '@splitlab/core';
1
+ import { EvalResult, ServerConfig, BootstrapData } from '@splitlab/core';
2
+ export { BootstrapData, EvalResult, ExclusionGroupConfig, ExperimentConfig, FlagConfig, SegmentConfig, ServerConfig, TargetingCondition, TargetingGroup, TargetingRules, TrackEvent, UAResult, Variant, hashToFloat, murmurhash3 } from '@splitlab/core';
3
3
 
4
4
  interface SplitLabServerConfig {
5
5
  apiKey: string;
@@ -31,6 +31,8 @@ declare class SplitLabServer {
31
31
  private configRefreshTimer;
32
32
  private lastEtag;
33
33
  private ready;
34
+ private trackedExposures;
35
+ private static readonly MAX_EXPOSURE_CACHE;
34
36
  constructor(config: SplitLabServerConfig);
35
37
  initialize(): Promise<void>;
36
38
  isReady(): boolean;
@@ -38,10 +40,60 @@ declare class SplitLabServer {
38
40
  getVariant(experimentKey: string, distinctId: string, attributes?: Record<string, any>): string | null;
39
41
  isFeatureEnabled(flagKey: string, distinctId: string, attributes?: Record<string, any>): boolean;
40
42
  evaluateAll(distinctId: string, attributes?: Record<string, any>): EvalResult;
43
+ getConfigSnapshot(): {
44
+ serverConfig: ServerConfig | null;
45
+ lastEtag: string | null;
46
+ };
47
+ /**
48
+ * Create a request-scoped context that enriches events with server-side
49
+ * properties parsed from the HTTP request (UA, URL, cookies, UTM, etc.).
50
+ *
51
+ * Reads `_sl_did` (device ID) and `_sl_sid` (session ID) cookies.
52
+ * If device ID is missing, generates one — call `ctx.getResponseCookies()`
53
+ * to get Set-Cookie headers for the response.
54
+ */
55
+ withContext(req: IncomingRequest): RequestContext;
41
56
  track(distinctId: string, eventName: string, properties?: Record<string, any>): void;
57
+ private trackExposure;
42
58
  flush(): Promise<void>;
43
59
  refresh(): Promise<void>;
44
60
  private fetchConfig;
45
61
  }
62
+ /** Minimal request shape — works with Express, Node http, or plain objects. */
63
+ interface IncomingRequest {
64
+ headers: Record<string, string | string[] | undefined> | Headers;
65
+ url?: string;
66
+ cookies?: Record<string, string>;
67
+ }
68
+ declare class RequestContext {
69
+ private server;
70
+ private props;
71
+ private _deviceId;
72
+ private _sessionId;
73
+ private _newDeviceId;
74
+ private _newSessionId;
75
+ constructor(server: SplitLabServer, req: IncomingRequest);
76
+ /** The device ID (from cookie or newly generated). */
77
+ get deviceId(): string;
78
+ /** The session ID (from cookie or newly generated). */
79
+ get sessionId(): string | null;
80
+ /** Evaluate an experiment variant with request context enrichment. */
81
+ getVariant(experimentKey: string, distinctId?: string): string | null;
82
+ /** Evaluate a feature flag. */
83
+ isFeatureEnabled(flagKey: string, distinctId?: string, attributes?: Record<string, any>): boolean;
84
+ /** Track an event enriched with request context properties. */
85
+ track(eventName: string, properties?: Record<string, any>, distinctId?: string): void;
86
+ /**
87
+ * Returns Set-Cookie headers to include in the HTTP response.
88
+ * Sets `_sl_did` (2yr) if new, always refreshes `_sl_sid` (30min sliding).
89
+ */
90
+ getResponseCookies(): string[];
91
+ /**
92
+ * Generate bootstrap data for client-side hydration.
93
+ * Evaluates all experiments/flags (pure, no exposure events) and packages
94
+ * the result with device ID, session ID, server config, and ETag.
95
+ */
96
+ getBootstrapData(distinctId?: string, attributes?: Record<string, any>): BootstrapData;
97
+ }
46
98
 
47
- export { SplitLabServer, type SplitLabServerConfig };
99
+ export { type IncomingRequest, RequestContext, SplitLabServer, type SplitLabServerConfig };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{murmurhash3 as R,hashToFloat as b}from"@splitlab/core";import{murmurhash3 as h,localEvaluate as p,evaluateRules as f}from"@splitlab/core";var u=class{constructor(e){this.serverConfig=null;this.eventQueue=[];this.flushTimer=null;this.configRefreshTimer=null;this.lastEtag=null;this.ready=!1;this.apiKey=e.apiKey,this.baseUrl=e.baseUrl.replace(/\/$/,""),this.ingestUrl=(e.ingestUrl||e.baseUrl).replace(/\/$/,""),this.configRefreshInterval=e.configRefreshInterval??3e4,this.flushInterval=e.flushInterval??1e4,this.flushSize=e.flushSize??100,this.onConfigUpdate=e.onConfigUpdate??null,this.environment=e.environment??"production"}async initialize(){let{config:e,etag:i}=await this.fetchConfig();this.serverConfig=e,this.lastEtag=i,this.configRefreshTimer=setInterval(()=>{this.refresh().catch(()=>{})},this.configRefreshInterval),this.flushTimer=setInterval(()=>{this.flush().catch(()=>{})},this.flushInterval),this.ready=!0}isReady(){return this.ready}async destroy(){this.configRefreshTimer!==null&&(clearInterval(this.configRefreshTimer),this.configRefreshTimer=null),this.flushTimer!==null&&(clearInterval(this.flushTimer),this.flushTimer=null),await this.flush(),this.ready=!1}getVariant(e,i,n){if(!this.serverConfig)return null;let t=this.serverConfig.experiments.find(r=>r.key===e);if(!t)return null;let a=this.serverConfig.segments||[];if(t.targeting_rules&&!f(t.targeting_rules,n||{},a))return null;let s=h(t.key+":"+i);if(s%1e4/100>=t.traffic_percentage)return null;let o=t.variants.reduce((r,v)=>r+v.weight,0),c=s%o,l=0;for(let r of t.variants)if(l+=r.weight,c<l)return r.key;return t.variants[t.variants.length-1].key}isFeatureEnabled(e,i,n){if(!this.serverConfig)return!1;let t=this.serverConfig.flags.find(o=>o.key===e);if(!t)return!1;let a=this.serverConfig.segments||[];return t.rules&&!f(t.rules,n||{},a)?!1:h(t.key+":"+i)%100<t.rollout_percentage}evaluateAll(e,i){return this.serverConfig?p(this.serverConfig,e,i||{}):{experiments:{},flags:{}}}track(e,i,n){this.eventQueue.push({distinct_id:e,event_name:i,properties:n,timestamp:new Date().toISOString()}),this.eventQueue.length>=this.flushSize&&this.flush().catch(()=>{})}async flush(){if(this.eventQueue.length===0)return;let e=this.eventQueue;this.eventQueue=[];try{(await fetch(`${this.ingestUrl}/ingest/batch`,{method:"POST",headers:{"Content-Type":"application/json","X-API-Key":this.apiKey},body:JSON.stringify({events:e})})).ok||(this.eventQueue=e.concat(this.eventQueue))}catch{this.eventQueue=e.concat(this.eventQueue)}}async refresh(){try{let{config:e,etag:i,notModified:n}=await this.fetchConfig();if(n)return;this.serverConfig=e,this.lastEtag=i,this.onConfigUpdate&&this.onConfigUpdate()}catch{}}async fetchConfig(){let e={"Content-Type":"application/json"};this.lastEtag&&(e["If-None-Match"]=this.lastEtag);let i=this.environment!=="production"?`&env=${encodeURIComponent(this.environment)}`:"",n=await fetch(`${this.baseUrl}/api/sdk/config?key=${encodeURIComponent(this.apiKey)}${i}`,{method:"GET",headers:e});if(n.status===304)return{config:this.serverConfig,etag:this.lastEtag,notModified:!0};if(!n.ok){let s=await n.text().catch(()=>"");throw new Error(`SplitLab API error ${n.status}: ${s}`)}let t=n.headers.get("etag");return{config:await n.json(),etag:t}}};export{u as SplitLabServer,b as hashToFloat,R as murmurhash3};
1
+ import{murmurhash3 as A,hashToFloat as O}from"@splitlab/core";import{murmurhash3 as v,localEvaluate as E,evaluateRules as d,parseUserAgent as R}from"@splitlab/core";var f=class f{constructor(e){this.serverConfig=null;this.eventQueue=[];this.flushTimer=null;this.configRefreshTimer=null;this.lastEtag=null;this.ready=!1;this.trackedExposures=new Set;this.apiKey=e.apiKey,this.baseUrl=e.baseUrl.replace(/\/$/,""),this.ingestUrl=(e.ingestUrl||e.baseUrl).replace(/\/$/,""),this.configRefreshInterval=e.configRefreshInterval??3e4,this.flushInterval=e.flushInterval??1e4,this.flushSize=e.flushSize??100,this.onConfigUpdate=e.onConfigUpdate??null,this.environment=e.environment??"production"}async initialize(){let{config:e,etag:s}=await this.fetchConfig();this.serverConfig=e,this.lastEtag=s,this.configRefreshTimer=setInterval(()=>{this.refresh().catch(()=>{})},this.configRefreshInterval),this.flushTimer=setInterval(()=>{this.flush().catch(()=>{})},this.flushInterval),this.ready=!0}isReady(){return this.ready}async destroy(){this.configRefreshTimer!==null&&(clearInterval(this.configRefreshTimer),this.configRefreshTimer=null),this.flushTimer!==null&&(clearInterval(this.flushTimer),this.flushTimer=null),await this.flush(),this.ready=!1}getVariant(e,s,t){if(!this.serverConfig)return null;let r=this.serverConfig.experiments.find(o=>o.key===e);if(!r)return null;let n=this.serverConfig.segments||[];if(r.targeting_rules&&!d(r.targeting_rules,t||{},n))return null;let i=v(r.key+":"+s);if(i%1e4/100>=r.traffic_percentage)return null;let l=r.variants.reduce((o,y)=>o+y.weight,0),u=i%l,h=0;for(let o of r.variants)if(h+=o.weight,u<h)return this.trackExposure(e,o.key,s),o.key;let c=r.variants[r.variants.length-1].key;return this.trackExposure(e,c,s),c}isFeatureEnabled(e,s,t){if(!this.serverConfig)return!1;let r=this.serverConfig.flags.find(l=>l.key===e);if(!r)return!1;let n=this.serverConfig.segments||[];return r.rules&&!d(r.rules,t||{},n)?!1:v(r.key+":"+s)%100<r.rollout_percentage}evaluateAll(e,s){return this.serverConfig?E(this.serverConfig,e,s||{}):{experiments:{},flags:{}}}getConfigSnapshot(){return{serverConfig:this.serverConfig,lastEtag:this.lastEtag}}withContext(e){return new p(this,e)}track(e,s,t){this.eventQueue.push({distinct_id:e,event_name:s,properties:t,timestamp:new Date().toISOString()}),this.eventQueue.length>=this.flushSize&&this.flush().catch(()=>{})}trackExposure(e,s,t){let r=`${e}:${t}`;this.trackedExposures.has(r)||(this.trackedExposures.size>=f.MAX_EXPOSURE_CACHE&&this.trackedExposures.clear(),this.trackedExposures.add(r),this.track(t,"$exposure",{experiment_key:e,variant_key:s}))}async flush(){if(this.eventQueue.length===0)return;let e=this.eventQueue;this.eventQueue=[];try{(await fetch(`${this.ingestUrl}/ingest/batch`,{method:"POST",headers:{"Content-Type":"application/json","X-API-Key":this.apiKey},body:JSON.stringify({events:e})})).ok||(this.eventQueue=e.concat(this.eventQueue))}catch{this.eventQueue=e.concat(this.eventQueue)}}async refresh(){try{let{config:e,etag:s,notModified:t}=await this.fetchConfig();if(t)return;this.serverConfig=e,this.lastEtag=s,this.onConfigUpdate&&this.onConfigUpdate()}catch{}}async fetchConfig(){let e={"Content-Type":"application/json"};this.lastEtag&&(e["If-None-Match"]=this.lastEtag);let s=this.environment!=="production"?`&env=${encodeURIComponent(this.environment)}`:"",t=await fetch(`${this.baseUrl}/api/sdk/config?key=${encodeURIComponent(this.apiKey)}${s}`,{method:"GET",headers:e});if(t.status===304)return{config:this.serverConfig,etag:this.lastEtag,notModified:!0};if(!t.ok){let i=await t.text().catch(()=>"");throw new Error(`SplitLab API error ${t.status}: ${i}`)}let r=t.headers.get("etag");return{config:await t.json(),etag:r}}};f.MAX_EXPOSURE_CACHE=5e4;var m=f,_="_sl_did",C="_sl_sid",S=63072e3,b=1800,k=["utm_source","utm_medium","utm_campaign","utm_term","utm_content"],p=class{constructor(e,s){this.server=e;let t=x(s.headers),r=s.cookies||w(t.cookie||""),n=R(t["user-agent"]||void 0),i=U(s.url,t.host),g=r[_]||null;this._deviceId=g||I(),this._newDeviceId=!g;let l=r[C]||null;this._sessionId=l||I(),this._newSessionId=!l,this.props={},n.browser&&(this.props.browser=n.browser),n.browser_version&&(this.props.browser_version=n.browser_version),n.os&&(this.props.os=n.os),n.os_version&&(this.props.os_version=n.os_version),n.device_type&&(this.props.device_type=n.device_type),t["user-agent"]&&(this.props.user_agent=t["user-agent"]),i.pathname&&(this.props.pathname=i.pathname),i.hostname&&(this.props.hostname=i.hostname);let u=t.referer||t.referrer||null;u&&(this.props.referrer=u);let h=t["accept-language"];if(h&&(this.props.language=h.split(",")[0].split(";")[0].trim()),i.searchParams)for(let c of k){let o=i.searchParams.get(c);o&&(this.props[c]=o)}this.props.device_id=this._deviceId,this._sessionId&&(this.props.session_id=this._sessionId)}get deviceId(){return this._deviceId}get sessionId(){return this._sessionId}getVariant(e,s){return this.server.getVariant(e,s||this._deviceId)}isFeatureEnabled(e,s,t){return this.server.isFeatureEnabled(e,s||this._deviceId,t)}track(e,s,t){this.server.track(t||this._deviceId,e,{...this.props,...s})}getResponseCookies(){let e=[];return this._newDeviceId&&e.push(`${_}=${encodeURIComponent(this._deviceId)}; Path=/; Max-Age=${S}; SameSite=Lax`),this._sessionId&&e.push(`${C}=${encodeURIComponent(this._sessionId)}; Path=/; Max-Age=${b}; SameSite=Lax`),e}getBootstrapData(e,s){let t=e||this._deviceId,r=this.server.evaluateAll(t,s),{serverConfig:n,lastEtag:i}=this.server.getConfigSnapshot();return{evalResult:r,serverConfig:n||{experiments:[],flags:[]},deviceId:this._deviceId,sessionId:this._sessionId,etag:i,generatedAt:new Date().toISOString()}}};function I(){let a=Date.now().toString(36),e=Math.random().toString(36).substring(2,10);return`${a}-${e}`}function x(a){let e={};if(a instanceof Headers)a.forEach((s,t)=>{e[t]=s});else for(let[s,t]of Object.entries(a))t!==void 0&&(e[s.toLowerCase()]=Array.isArray(t)?t[0]:t);return e}function w(a){let e={};if(!a)return e;for(let s of a.split(";")){let t=s.indexOf("=");if(t===-1)continue;let r=s.substring(0,t).trim(),n=s.substring(t+1).trim();try{e[r]=decodeURIComponent(n)}catch{e[r]=n}}return e}function U(a,e){if(!a)return{pathname:null,hostname:null,searchParams:null};try{let s=e?`http://${e}`:"http://localhost",t=new URL(a,s);return{pathname:t.pathname,hostname:t.hostname!=="localhost"?t.hostname:e?.split(":")[0]||null,searchParams:t.searchParams}}catch{return{pathname:a.split("?")[0]||null,hostname:null,searchParams:null}}}export{p as RequestContext,m as SplitLabServer,O as hashToFloat,A as murmurhash3};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@splitlab/node",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Node.js server SDK for SplitLab A/B testing and feature flags",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -33,7 +33,7 @@
33
33
  "dev": "tsup --watch"
34
34
  },
35
35
  "dependencies": {
36
- "@splitlab/core": "^0.2.0"
36
+ "@splitlab/core": "^0.3.2"
37
37
  },
38
38
  "devDependencies": {
39
39
  "tsup": "^8.0.0",