feat: Complete zCode CLI X with Telegram bot integration

- Add full Telegram bot functionality with Z.AI API integration
- Implement 4 tools: Bash, FileEdit, WebSearch, Git
- Add 3 agents: Code Reviewer, Architect, DevOps Engineer
- Add 6 skills for common coding tasks
- Add systemd service file for 24/7 operation
- Add nginx configuration for HTTPS webhook
- Add comprehensive documentation
- Implement WebSocket server for real-time updates
- Add logging system with Winston
- Add environment validation

🤖 zCode CLI X - Agentic coder with Z.AI + Telegram integration
This commit is contained in:
admin
2026-05-05 09:01:26 +00:00
Unverified
parent 4a7035dd92
commit 875c7f9b91
24688 changed files with 3224957 additions and 221 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,484 @@
import type {
ApiHost,
Attributes,
AutoExperiment,
ClientKey,
ClientOptions,
DestroyOptions,
EvalContext,
EventLogger,
EventProperties,
Experiment,
FeatureApiResponse,
FeatureDefinitions,
FeatureResult,
FeatureUsageCallback,
GlobalContext,
InitOptions,
InitResponse,
InitSyncOptions,
LogUnion,
Plugin,
RefreshFeaturesOptions,
Result,
TrackingCallback,
TrackingCallbackWithUser,
UserContext,
WidenPrimitives,
} from "./types/growthbook";
import { loadSDKVersion } from "./util";
import {
clearAutoRefresh,
configureCache,
refreshFeatures,
startStreaming,
unsubscribe,
} from "./feature-repository";
import {
decryptPayload,
evalFeature as _evalFeature,
getAllStickyBucketAssignmentDocs,
getApiHosts,
runExperiment,
} from "./core";
import { StickyBucketService } from "./sticky-bucket-service";
const SDK_VERSION = loadSDKVersion();
export class GrowthBookClient<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
AppFeatures extends Record<string, any> = Record<string, any>,
> {
public debug: boolean;
public ready: boolean;
public version: string;
// Properties and methods that start with "_" are mangled by Terser (saves ~150 bytes)
private _options: ClientOptions;
private _features: FeatureDefinitions;
private _experiments: AutoExperiment[];
private _payload: FeatureApiResponse | undefined;
private _decryptedPayload: FeatureApiResponse | undefined;
private _destroyed?: boolean;
constructor(options?: ClientOptions) {
options = options || {};
// These properties are all initialized in the constructor instead of above
// This saves ~80 bytes in the final output
this.version = SDK_VERSION;
this._options = options;
this.debug = !!options.debug;
this.ready = false;
this._features = {};
this._experiments = [];
this.log = this.log.bind(this);
if (options.plugins) {
for (const plugin of options.plugins) {
plugin(this);
}
}
}
public async setPayload(payload: FeatureApiResponse): Promise<void> {
this._payload = payload;
const data = await decryptPayload(payload, this._options.decryptionKey);
this._decryptedPayload = data;
if (data.features) {
this._features = data.features;
}
if (data.experiments) {
this._experiments = data.experiments;
}
if (data.savedGroups) {
this._options.savedGroups = data.savedGroups;
}
this.ready = true;
}
public initSync(options: InitSyncOptions): GrowthBookClient<AppFeatures> {
const payload = options.payload;
if (payload.encryptedExperiments || payload.encryptedFeatures) {
throw new Error("initSync does not support encrypted payloads");
}
this._payload = payload;
this._decryptedPayload = payload;
if (payload.features) {
this._features = payload.features;
}
if (payload.experiments) {
this._experiments = payload.experiments;
}
this.ready = true;
startStreaming(this, options);
return this;
}
public async init(options?: InitOptions): Promise<InitResponse> {
options = options || {};
if (options.cacheSettings) {
configureCache(options.cacheSettings);
}
if (options.payload) {
await this.setPayload(options.payload);
startStreaming(this, options);
return {
success: true,
source: "init",
};
} else {
const { data, ...res } = await this._refresh({
...options,
allowStale: true,
});
startStreaming(this, options);
await this.setPayload(data || {});
return res;
}
}
public async refreshFeatures(
options?: RefreshFeaturesOptions,
): Promise<void> {
const res = await this._refresh({
...(options || {}),
allowStale: false,
});
if (res.data) {
await this.setPayload(res.data);
}
}
public getApiInfo(): [ApiHost, ClientKey] {
return [this.getApiHosts().apiHost, this.getClientKey()];
}
public getApiHosts() {
return getApiHosts(this._options);
}
public getClientKey(): string {
return this._options.clientKey || "";
}
public getPayload(): FeatureApiResponse {
return (
this._payload || {
features: this.getFeatures(),
experiments: this._experiments || [],
}
);
}
public getDecryptedPayload(): FeatureApiResponse {
return this._decryptedPayload || this.getPayload();
}
private async _refresh({
timeout,
skipCache,
allowStale,
streaming,
}: RefreshFeaturesOptions & {
allowStale?: boolean;
streaming?: boolean;
}) {
if (!this._options.clientKey) {
throw new Error("Missing clientKey");
}
// Trigger refresh in feature repository
return refreshFeatures({
instance: this,
timeout,
skipCache: skipCache || this._options.disableCache,
allowStale,
backgroundSync: streaming ?? true,
});
}
public getFeatures() {
return this._features || {};
}
public getGlobalAttributes(): Attributes {
return this._options.globalAttributes || {};
}
public setGlobalAttributes(attributes: Attributes) {
this._options.globalAttributes = attributes;
}
public destroy(options?: DestroyOptions) {
options = options || {};
this._destroyed = true;
unsubscribe(this);
if (options.destroyAllStreams) {
clearAutoRefresh();
}
// Release references to save memory
this._features = {};
this._experiments = [];
this._decryptedPayload = undefined;
this._payload = undefined;
this._options = {};
}
public isDestroyed() {
return !!this._destroyed;
}
public setEventLogger(logger: EventLogger) {
this._options.eventLogger = logger;
}
public logEvent(
eventName: string,
properties: EventProperties,
userContext: UserContext,
) {
if (this._options.eventLogger) {
const ctx = this._getEvalContext(userContext);
this._options.eventLogger(eventName, properties, ctx.user);
}
}
public runInlineExperiment<T>(
experiment: Experiment<T>,
userContext: UserContext,
): Result<T> {
const { result } = runExperiment(
experiment,
null,
this._getEvalContext(userContext),
);
return result;
}
private _getEvalContext(userContext: UserContext): EvalContext {
if (this._options.globalAttributes) {
userContext = {
...userContext,
attributes: {
...this._options.globalAttributes,
...userContext.attributes,
},
};
}
return {
user: userContext,
global: this._getGlobalContext(),
stack: {
evaluatedFeatures: new Set(),
},
};
}
private _getGlobalContext(): GlobalContext {
return {
features: this._features,
experiments: this._experiments,
log: this.log,
enabled: this._options.enabled,
qaMode: this._options.qaMode,
savedGroups: this._options.savedGroups,
forcedFeatureValues: this._options.forcedFeatureValues,
forcedVariations: this._options.forcedVariations,
trackingCallback: this._options.trackingCallback,
onFeatureUsage: this._options.onFeatureUsage,
};
}
public isOn<K extends string & keyof AppFeatures = string>(
key: K,
userContext: UserContext,
): boolean {
return this.evalFeature(key, userContext).on;
}
public isOff<K extends string & keyof AppFeatures = string>(
key: K,
userContext: UserContext,
): boolean {
return this.evalFeature(key, userContext).off;
}
public getFeatureValue<
V extends AppFeatures[K],
K extends string & keyof AppFeatures = string,
>(key: K, defaultValue: V, userContext: UserContext): WidenPrimitives<V> {
const value = this.evalFeature<WidenPrimitives<V>, K>(
key,
userContext,
).value;
return value === null ? (defaultValue as WidenPrimitives<V>) : value;
}
public evalFeature<
V extends AppFeatures[K],
K extends string & keyof AppFeatures = string,
>(id: K, userContext: UserContext): FeatureResult<V | null> {
return _evalFeature(id, this._getEvalContext(userContext));
}
log(msg: string, ctx: Record<string, unknown>) {
if (!this.debug) return;
if (this._options.log) this._options.log(msg, ctx);
else console.log(msg, ctx);
}
public setTrackingCallback(callback: TrackingCallbackWithUser) {
this._options.trackingCallback = callback;
}
public setFeatureUsageCallback(callback: FeatureUsageCallback) {
this._options.onFeatureUsage = callback;
}
public async applyStickyBuckets(
partialContext: Omit<
UserContext,
"stickyBucketService" | "stickyBucketAssignmentDocs"
>,
stickyBucketService: StickyBucketService,
): Promise<UserContext> {
const ctx = this._getEvalContext(partialContext);
const stickyBucketAssignmentDocs = await getAllStickyBucketAssignmentDocs(
ctx,
stickyBucketService,
);
return {
...partialContext,
stickyBucketAssignmentDocs,
saveStickyBucketAssignmentDoc: (doc) =>
stickyBucketService.saveAssignments(doc),
};
}
public createScopedInstance(
userContext: UserContext,
userPlugins?: Plugin[],
) {
return new UserScopedGrowthBook(this, userContext, [
...(this._options.plugins || []),
...(userPlugins || []),
]);
}
}
export class UserScopedGrowthBook<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
AppFeatures extends Record<string, any> = Record<string, any>,
> {
private _gb: GrowthBookClient;
private _userContext: UserContext;
public logs: Array<LogUnion>;
constructor(
gb: GrowthBookClient<AppFeatures>,
userContext: UserContext,
plugins?: Plugin[],
) {
this._gb = gb;
this._userContext = userContext;
this.logs = [];
this._userContext.trackedExperiments =
this._userContext.trackedExperiments || new Set();
this._userContext.trackedFeatureUsage =
this._userContext.trackedFeatureUsage || {};
this._userContext.devLogs = this.logs;
if (plugins) {
for (const plugin of plugins) {
plugin(this);
}
}
}
public runInlineExperiment<T>(experiment: Experiment<T>): Result<T> {
return this._gb.runInlineExperiment(experiment, this._userContext);
}
public isOn<K extends string & keyof AppFeatures = string>(key: K): boolean {
return this._gb.isOn(key, this._userContext);
}
public isOff<K extends string & keyof AppFeatures = string>(key: K): boolean {
return this._gb.isOff(key, this._userContext);
}
public getFeatureValue<
V extends AppFeatures[K],
K extends string & keyof AppFeatures = string,
>(key: K, defaultValue: V): WidenPrimitives<V> {
return this._gb.getFeatureValue(key, defaultValue, this._userContext);
}
public evalFeature<
V extends AppFeatures[K],
K extends string & keyof AppFeatures = string,
>(id: K): FeatureResult<V | null> {
return this._gb.evalFeature(id, this._userContext);
}
public logEvent(eventName: string, properties?: EventProperties) {
if (this._userContext.enableDevMode) {
this.logs.push({
eventName,
properties,
timestamp: Date.now().toString(),
logType: "event",
});
}
this._gb.logEvent(eventName, properties || {}, this._userContext);
}
public setTrackingCallback(cb: TrackingCallback) {
this._userContext.trackingCallback = cb;
}
public getApiInfo(): [ApiHost, ClientKey] {
return this._gb.getApiInfo();
}
public getClientKey() {
return this._gb.getClientKey();
}
public setURL(url: string) {
this._userContext.url = url;
}
public updateAttributes(attributes: Attributes) {
this._userContext.attributes = {
...this._userContext.attributes,
...attributes,
};
}
public setAttributeOverrides(overrides: Attributes) {
this._userContext.attributeOverrides = overrides;
}
public async setForcedVariations(vars: Record<string, number>) {
this._userContext.forcedVariations = vars || {};
}
// eslint-disable-next-line
public setForcedFeatures(map: Map<string, any>) {
this._userContext.forcedFeatureValues = map;
}
public getUserContext() {
return this._userContext;
}
public getVersion() {
return SDK_VERSION;
}
public getDecryptedPayload() {
return this._gb.getDecryptedPayload();
}
public inDevMode(): boolean {
return !!this._userContext.enableDevMode;
}
}

View File

@@ -0,0 +1,221 @@
import Cookies from "js-cookie";
import {
CacheSettings,
Options as Context,
FeatureApiResponse,
Plugin,
TrackingCallback,
} from "./types/growthbook";
import { GrowthBook } from "./GrowthBook";
import {
BrowserCookieStickyBucketService,
LocalStorageStickyBucketService,
StickyBucketService,
} from "./sticky-bucket-service";
import { autoAttributesPlugin } from "./plugins/auto-attributes";
import { growthbookTrackingPlugin } from "./plugins/growthbook-tracking";
import {
thirdPartyTrackingPlugin,
Trackers,
} from "./plugins/third-party-tracking";
type WindowContext = Context & {
uuidCookieName?: string;
uuidKey?: string;
uuid?: string;
persistUuidOnLoad?: boolean;
noStreaming?: boolean;
useStickyBucketService?: "cookie" | "localStorage";
stickyBucketPrefix?: string;
payload?: FeatureApiResponse;
cacheSettings?: CacheSettings;
antiFlicker?: boolean;
antiFlickerTimeout?: number;
additionalTrackingCallback?: TrackingCallback;
};
declare global {
interface Window {
_growthbook?: GrowthBook;
growthbook_queue?:
| Array<(gb: GrowthBook) => void>
| { push: (cb: (gb: GrowthBook) => void) => void };
growthbook_config?: WindowContext;
dataLayer?: unknown[];
analytics?: {
track?: (name: string, props?: Record<string, unknown>) => void;
};
gtag?: (...args: unknown[]) => void;
}
}
// Ensure dataLayer exists
window.dataLayer = window.dataLayer || [];
const currentScript = document.currentScript;
const dataContext: DOMStringMap = currentScript ? currentScript.dataset : {};
const windowContext: WindowContext = window.growthbook_config || {};
let antiFlickerTimeout: number | undefined;
function setAntiFlicker() {
window.clearTimeout(antiFlickerTimeout);
let timeoutMs =
windowContext.antiFlickerTimeout ??
(dataContext.antiFlickerTimeout
? parseInt(dataContext.antiFlickerTimeout)
: null) ??
3500;
if (!isFinite(timeoutMs)) {
timeoutMs = 3500;
}
try {
if (!document.getElementById("gb-anti-flicker-style")) {
const styleTag = document.createElement("style");
styleTag.setAttribute("id", "gb-anti-flicker-style");
styleTag.innerHTML =
".gb-anti-flicker { opacity: 0 !important; pointer-events: none; }";
document.head.appendChild(styleTag);
}
document.documentElement.classList.add("gb-anti-flicker");
// Fallback if GrowthBook fails to load in specified time or 3.5 seconds.
antiFlickerTimeout = window.setTimeout(unsetAntiFlicker, timeoutMs);
} catch (e) {
console.error(e);
}
}
function unsetAntiFlicker() {
window.clearTimeout(antiFlickerTimeout);
try {
document.documentElement.classList.remove("gb-anti-flicker");
} catch (e) {
console.error(e);
}
}
if (windowContext.antiFlicker || dataContext.antiFlicker) {
setAntiFlicker();
}
// Create sticky bucket service
let stickyBucketService: StickyBucketService | undefined = undefined;
if (
windowContext.useStickyBucketService === "cookie" ||
dataContext.useStickyBucketService === "cookie"
) {
stickyBucketService = new BrowserCookieStickyBucketService({
prefix:
windowContext.stickyBucketPrefix ||
dataContext.stickyBucketPrefix ||
undefined,
jsCookie: Cookies,
});
} else if (
windowContext.useStickyBucketService === "localStorage" ||
dataContext.useStickyBucketService === "localStorage"
) {
stickyBucketService = new LocalStorageStickyBucketService({
prefix:
windowContext.stickyBucketPrefix ||
dataContext.stickyBucketPrefix ||
undefined,
});
}
const uuid = dataContext.uuid || windowContext.uuid;
const plugins: Plugin[] = [
autoAttributesPlugin({
uuid,
uuidCookieName: windowContext.uuidCookieName || dataContext.uuidCookieName,
uuidKey: windowContext.uuidKey || dataContext.uuidKey,
uuidAutoPersist: !uuid && dataContext.noAutoCookies == null,
}),
];
const tracking = dataContext.tracking || "gtag,gtm,segment";
if (tracking !== "none") {
const trackers = tracking
.toLowerCase()
.split(",")
.map((t) => t.trim());
if (trackers.includes("growthbook")) {
plugins.push(
growthbookTrackingPlugin({
ingestorHost: dataContext.eventIngestorHost,
}),
);
}
if (!windowContext.trackingCallback) {
plugins.push(
thirdPartyTrackingPlugin({
additionalCallback: windowContext.additionalTrackingCallback,
trackers: trackers as Trackers[],
}),
);
}
}
// Create GrowthBook instance
const gb = new GrowthBook({
enableDevMode: true,
...dataContext,
remoteEval: !!dataContext.remoteEval,
...windowContext,
plugins,
stickyBucketService,
});
// Set the renderer to fire a custom DOM event
// This will let us attach multiple listeners
gb.setRenderer(() => {
document.dispatchEvent(new CustomEvent("growthbookdata"));
});
gb.init({
payload: windowContext.payload,
streaming: !(
windowContext.noStreaming ||
dataContext.noStreaming ||
windowContext.backgroundSync === false
),
cacheSettings: windowContext.cacheSettings,
}).then(() => {
if (!(windowContext.antiFlicker || dataContext.antiFlicker)) return;
if (gb.getRedirectUrl()) {
setAntiFlicker();
} else {
unsetAntiFlicker();
}
});
const fireCallback = (cb: (gb: GrowthBook) => void) => {
try {
cb && cb(gb);
} catch (e) {
console.error("Uncaught growthbook_queue error", e);
}
};
// Process any queued callbacks
if (window.growthbook_queue) {
if (Array.isArray(window.growthbook_queue)) {
window.growthbook_queue.forEach((cb) => {
fireCallback(cb);
});
}
}
// Replace the queue with a function that immediately calls the callback
window.growthbook_queue = {
push: (cb: (gb: GrowthBook) => void) => {
fireCallback(cb);
},
};
// Store a reference in window to enable more advanced use cases
export default gb;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,581 @@
import {
Attributes,
CacheSettings,
FeatureApiResponse,
FetchResponse,
Helpers,
Polyfills,
} from "./types/growthbook";
import { getPolyfills, promiseTimeout } from "./util";
import type {
GrowthBook,
InitOptions,
InitSyncOptions,
GrowthBookClient,
} from ".";
type CacheEntry = {
data: FeatureApiResponse;
sse?: boolean;
version: string;
staleAt: Date;
};
type ScopedChannel = {
src: EventSource | null;
cb: (event: MessageEvent<string>) => void;
host: string;
clientKey: string;
headers?: Record<string, string>;
errors: number;
state: "active" | "idle" | "disabled";
};
// Config settings
const cacheSettings: CacheSettings = {
// Consider a fetch stale after 1 minute
staleTTL: 1000 * 60,
// Max time to keep a fetch in cache (4 hours default)
maxAge: 1000 * 60 * 60 * 4,
cacheKey: "gbFeaturesCache",
backgroundSync: true,
maxEntries: 10,
disableIdleStreams: false,
idleStreamInterval: 20000,
disableCache: false,
};
const polyfills = getPolyfills();
export const helpers: Helpers = {
fetchFeaturesCall: ({ host, clientKey, headers }) => {
return (polyfills.fetch as typeof globalThis.fetch)(
`${host}/api/features/${clientKey}`,
{ headers },
);
},
fetchRemoteEvalCall: ({ host, clientKey, payload, headers }) => {
const options = {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(payload),
};
return (polyfills.fetch as typeof globalThis.fetch)(
`${host}/api/eval/${clientKey}`,
options,
);
},
eventSourceCall: ({ host, clientKey, headers }) => {
if (headers) {
return new polyfills.EventSource(`${host}/sub/${clientKey}`, {
headers,
});
}
return new polyfills.EventSource(`${host}/sub/${clientKey}`);
},
startIdleListener: () => {
let idleTimeout: number | undefined;
const isBrowser =
typeof window !== "undefined" && typeof document !== "undefined";
if (!isBrowser) return;
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
window.clearTimeout(idleTimeout);
onVisible();
} else if (document.visibilityState === "hidden") {
idleTimeout = window.setTimeout(
onHidden,
cacheSettings.idleStreamInterval,
);
}
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", onVisibilityChange);
},
stopIdleListener: () => {
// No-op, replaced by startIdleListener
},
};
try {
if (globalThis.localStorage) {
polyfills.localStorage = globalThis.localStorage;
}
} catch (e) {
// Ignore localStorage errors
}
// Global state
const subscribedInstances: Map<
string,
Set<GrowthBook | GrowthBookClient>
> = new Map();
let cacheInitialized = false;
const cache: Map<string, CacheEntry> = new Map();
const activeFetches: Map<string, Promise<FetchResponse>> = new Map();
const streams: Map<string, ScopedChannel> = new Map();
const supportsSSE: Set<string> = new Set();
// Public functions
export function setPolyfills(overrides: Partial<Polyfills>): void {
Object.assign(polyfills, overrides);
}
export function configureCache(overrides: Partial<CacheSettings>): void {
Object.assign(cacheSettings, overrides);
if (!cacheSettings.backgroundSync) {
clearAutoRefresh();
}
}
export async function clearCache(): Promise<void> {
cache.clear();
activeFetches.clear();
clearAutoRefresh();
cacheInitialized = false;
await updatePersistentCache();
}
// Get or fetch features and refresh the SDK instance
export async function refreshFeatures({
instance,
timeout,
skipCache,
allowStale,
backgroundSync,
}: {
instance: GrowthBook | GrowthBookClient;
timeout?: number;
skipCache?: boolean;
allowStale?: boolean;
backgroundSync?: boolean;
}): Promise<FetchResponse> {
if (!backgroundSync) {
cacheSettings.backgroundSync = false;
}
return fetchFeaturesWithCache({
instance,
allowStale,
timeout,
skipCache,
});
}
// Subscribe a GrowthBook instance to feature changes
function subscribe(instance: GrowthBook | GrowthBookClient): void {
const key = getKey(instance);
const subs = subscribedInstances.get(key) || new Set();
subs.add(instance);
subscribedInstances.set(key, subs);
}
export function unsubscribe(instance: GrowthBook | GrowthBookClient): void {
subscribedInstances.forEach((s) => s.delete(instance));
}
export function onHidden() {
streams.forEach((channel) => {
if (!channel) return;
channel.state = "idle";
disableChannel(channel);
});
}
export function onVisible() {
streams.forEach((channel) => {
if (!channel) return;
if (channel.state !== "idle") return;
enableChannel(channel);
});
}
// Private functions
async function updatePersistentCache() {
try {
if (!polyfills.localStorage) return;
await polyfills.localStorage.setItem(
cacheSettings.cacheKey,
JSON.stringify(Array.from(cache.entries())),
);
} catch (e) {
// Ignore localStorage errors
}
}
// SWR wrapper for fetching features. May indirectly or directly start SSE streaming.
async function fetchFeaturesWithCache({
instance,
allowStale,
timeout,
skipCache,
}: {
instance: GrowthBook | GrowthBookClient;
allowStale?: boolean;
timeout?: number;
skipCache?: boolean;
}): Promise<FetchResponse> {
const key = getKey(instance);
const cacheKey = getCacheKey(instance);
const now = new Date();
const minStaleAt = new Date(
now.getTime() - cacheSettings.maxAge + cacheSettings.staleTTL,
);
await initializeCache();
const existing =
!cacheSettings.disableCache && !skipCache ? cache.get(cacheKey) : undefined;
if (
existing &&
(allowStale || existing.staleAt > now) &&
existing.staleAt > minStaleAt
) {
// Restore from cache whether SSE is supported
if (existing.sse) supportsSSE.add(key);
// Reload features in the background if stale
if (existing.staleAt < now) {
fetchFeatures(instance);
}
// Otherwise, if we don't need to refresh now, start a background sync
else {
startAutoRefresh(instance);
}
return { data: existing.data, success: true, source: "cache" };
} else {
const res = await promiseTimeout(fetchFeatures(instance), timeout);
return (
res || {
data: null,
success: false,
source: "timeout",
error: new Error("Timeout"),
}
);
}
}
function getKey(instance: GrowthBook | GrowthBookClient): string {
const [apiHost, clientKey] = instance.getApiInfo();
return `${apiHost}||${clientKey}`;
}
function getCacheKey(instance: GrowthBook | GrowthBookClient): string {
const baseKey = getKey(instance);
if (!("isRemoteEval" in instance) || !instance.isRemoteEval()) return baseKey;
const attributes = instance.getAttributes();
const cacheKeyAttributes =
instance.getCacheKeyAttributes() || Object.keys(instance.getAttributes());
const ca: Attributes = {};
cacheKeyAttributes.forEach((key) => {
ca[key] = attributes[key];
});
const fv = instance.getForcedVariations();
const url = instance.getUrl();
return `${baseKey}||${JSON.stringify({
ca,
fv,
url,
})}`;
}
// Populate cache from localStorage (if available)
async function initializeCache(): Promise<void> {
if (cacheInitialized) return;
cacheInitialized = true;
try {
if (polyfills.localStorage) {
const value = await polyfills.localStorage.getItem(
cacheSettings.cacheKey,
);
if (!cacheSettings.disableCache && value) {
const parsed: [string, CacheEntry][] = JSON.parse(value);
if (parsed && Array.isArray(parsed)) {
parsed.forEach(([key, data]) => {
cache.set(key, {
...data,
staleAt: new Date(data.staleAt),
});
});
}
cleanupCache();
}
}
} catch (e) {
// Ignore localStorage errors
}
if (!cacheSettings.disableIdleStreams) {
const cleanupFn = helpers.startIdleListener();
if (cleanupFn) {
helpers.stopIdleListener = cleanupFn;
}
}
}
// Enforce the maxEntries limit
function cleanupCache() {
const entriesWithTimestamps = Array.from(cache.entries())
.map(([key, value]) => ({
key,
staleAt: value.staleAt.getTime(),
}))
.sort((a, b) => a.staleAt - b.staleAt);
const entriesToRemoveCount = Math.min(
Math.max(0, cache.size - cacheSettings.maxEntries),
cache.size,
);
for (let i = 0; i < entriesToRemoveCount; i++) {
cache.delete(entriesWithTimestamps[i].key);
}
}
// Called whenever new features are fetched from the API
function onNewFeatureData(
key: string,
cacheKey: string,
data: FeatureApiResponse,
): void {
// If contents haven't changed, ignore the update, extend the stale TTL
const version = data.dateUpdated || "";
const staleAt = new Date(Date.now() + cacheSettings.staleTTL);
const existing = !cacheSettings.disableCache
? cache.get(cacheKey)
: undefined;
if (existing && version && existing.version === version) {
existing.staleAt = staleAt;
updatePersistentCache();
return;
}
if (!cacheSettings.disableCache) {
// Update in-memory cache
cache.set(cacheKey, {
data,
version,
staleAt,
sse: supportsSSE.has(key),
});
cleanupCache();
}
// Update local storage (don't await this, just update asynchronously)
updatePersistentCache();
// Update features for all subscribed GrowthBook instances
const instances = subscribedInstances.get(key);
instances && instances.forEach((instance) => refreshInstance(instance, data));
}
async function refreshInstance(
instance: GrowthBook | GrowthBookClient,
data: FeatureApiResponse | null,
): Promise<void> {
await instance.setPayload(data || instance.getPayload());
}
// Fetch the features payload from helper function or from in-mem injected payload
async function fetchFeatures(
instance: GrowthBook | GrowthBookClient,
): Promise<FetchResponse> {
const { apiHost, apiRequestHeaders } = instance.getApiHosts();
const clientKey = instance.getClientKey();
const remoteEval = "isRemoteEval" in instance && instance.isRemoteEval();
const key = getKey(instance);
const cacheKey = getCacheKey(instance);
let promise = activeFetches.get(cacheKey);
if (!promise) {
const fetcher: Promise<Response> = remoteEval
? helpers.fetchRemoteEvalCall({
host: apiHost,
clientKey,
payload: {
attributes: instance.getAttributes(),
forcedVariations: instance.getForcedVariations(),
forcedFeatures: Array.from(instance.getForcedFeatures().entries()),
url: instance.getUrl(),
},
headers: apiRequestHeaders,
})
: helpers.fetchFeaturesCall({
host: apiHost,
clientKey,
headers: apiRequestHeaders,
});
// TODO: auto-retry if status code indicates a temporary error
promise = fetcher
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP error: ${res.status}`);
}
if (res.headers.get("x-sse-support") === "enabled") {
supportsSSE.add(key);
}
return res.json();
})
.then((data: FeatureApiResponse) => {
onNewFeatureData(key, cacheKey, data);
startAutoRefresh(instance);
activeFetches.delete(cacheKey);
return { data, success: true, source: "network" as const };
})
.catch((e) => {
process.env.NODE_ENV !== "production" &&
instance.log("Error fetching features", {
apiHost,
clientKey,
error: e ? e.message : null,
});
activeFetches.delete(cacheKey);
return {
data: null,
source: "error" as const,
success: false,
error: e,
};
});
activeFetches.set(cacheKey, promise);
}
return promise;
}
// Start SSE streaming, listens to feature payload changes and triggers a refresh or re-fetch
function startAutoRefresh(
instance: GrowthBook | GrowthBookClient,
forceSSE: boolean = false,
): void {
const key = getKey(instance);
const cacheKey = getCacheKey(instance);
const { streamingHost, streamingHostRequestHeaders } = instance.getApiHosts();
const clientKey = instance.getClientKey();
if (forceSSE) {
supportsSSE.add(key);
}
if (
cacheSettings.backgroundSync &&
supportsSSE.has(key) &&
polyfills.EventSource
) {
if (streams.has(key)) return;
const channel: ScopedChannel = {
src: null,
host: streamingHost,
clientKey,
headers: streamingHostRequestHeaders,
cb: (event: MessageEvent<string>) => {
try {
if (event.type === "features-updated") {
const instances = subscribedInstances.get(key);
instances &&
instances.forEach((instance) => {
fetchFeatures(instance);
});
} else if (event.type === "features") {
const json: FeatureApiResponse = JSON.parse(event.data);
onNewFeatureData(key, cacheKey, json);
}
// Reset error count on success
channel.errors = 0;
} catch (e) {
process.env.NODE_ENV !== "production" &&
instance.log("SSE Error", {
streamingHost,
clientKey,
error: e ? (e as Error).message : null,
});
onSSEError(channel);
}
},
errors: 0,
state: "active",
};
streams.set(key, channel);
enableChannel(channel);
}
}
function onSSEError(channel: ScopedChannel) {
if (channel.state === "idle") return;
channel.errors++;
if (channel.errors > 3 || (channel.src && channel.src.readyState === 2)) {
// exponential backoff after 4 errors, with jitter
const delay =
Math.pow(3, channel.errors - 3) * (1000 + Math.random() * 1000);
disableChannel(channel);
setTimeout(
() => {
if (["idle", "active"].includes(channel.state)) return;
enableChannel(channel);
},
Math.min(delay, 300000),
); // 5 minutes max
}
}
function disableChannel(channel: ScopedChannel) {
if (!channel.src) return;
channel.src.onopen = null;
channel.src.onerror = null;
channel.src.close();
channel.src = null;
if (channel.state === "active") {
channel.state = "disabled";
}
}
function enableChannel(channel: ScopedChannel) {
channel.src = helpers.eventSourceCall({
host: channel.host,
clientKey: channel.clientKey,
headers: channel.headers,
}) as EventSource;
channel.state = "active";
channel.src.addEventListener("features", channel.cb);
channel.src.addEventListener("features-updated", channel.cb);
channel.src.onerror = () => onSSEError(channel);
channel.src.onopen = () => {
channel.errors = 0;
};
}
function destroyChannel(channel: ScopedChannel, key: string) {
disableChannel(channel);
streams.delete(key);
}
export function clearAutoRefresh() {
// Clear list of which keys are auto-updated
supportsSSE.clear();
// Stop listening for any SSE events
streams.forEach(destroyChannel);
// Remove all references to GrowthBook instances
subscribedInstances.clear();
// Run the idle stream cleanup function
helpers.stopIdleListener();
}
export function startStreaming(
instance: GrowthBook | GrowthBookClient,
options: InitOptions | InitSyncOptions,
) {
if (options.streaming) {
if (!instance.getClientKey()) {
throw new Error("Must specify clientKey to enable streaming");
}
if (options.payload) {
startAutoRefresh(instance, true);
}
subscribe(instance);
}
}

View File

@@ -0,0 +1,102 @@
export type {
Options as Context,
Options,
ClientOptions as MultiUserOptions,
ClientOptions,
TrackingCallbackWithUser,
TrackingDataWithUser,
FeatureUsageCallback,
FeatureUsageCallbackWithUser,
UserContext,
Attributes,
Polyfills,
CacheSettings,
FeatureApiResponse,
LoadFeaturesOptions,
RefreshFeaturesOptions,
DestroyOptions,
FeatureDefinitions,
FeatureDefinition,
FeatureRule,
FeatureResult,
FeatureResultSource,
Experiment,
Result,
ExperimentOverride,
ExperimentStatus,
JSONValue,
SubscriptionFunction,
LocalStorageCompat,
WidenPrimitives,
VariationMeta,
Filter,
VariationRange,
UrlTarget,
AutoExperiment,
AutoExperimentVariation,
AutoExperimentChangeType,
DOMMutation,
UrlTargetType,
RenderFunction,
StickyAttributeKey,
StickyExperimentKey,
StickyAssignments,
StickyAssignmentsDocument,
TrackingData,
TrackingCallback,
NavigateCallback,
ApplyDomChangesCallback,
InitOptions,
PrefetchOptions,
InitResponse,
InitSyncOptions,
Helpers,
GrowthBookPayload,
SavedGroupsValues,
EventLogger,
EventProperties,
Plugin,
LogUnion,
} from "./types/growthbook";
export type {
ConditionInterface,
ParentConditionInterface,
} from "./types/mongrule";
export {
setPolyfills,
clearCache,
configureCache,
helpers,
onVisible,
onHidden,
} from "./feature-repository";
export { GrowthBook, prefetchPayload } from "./GrowthBook";
export {
GrowthBookClient as GrowthBookMultiUser,
GrowthBookClient,
UserScopedGrowthBook,
} from "./GrowthBookClient";
export {
StickyBucketService,
StickyBucketServiceSync,
LocalStorageStickyBucketService,
ExpressCookieStickyBucketService,
BrowserCookieStickyBucketService,
RedisStickyBucketService,
} from "./sticky-bucket-service";
export { evalCondition } from "./mongrule";
export {
isURLTargeted,
getPolyfills,
getAutoExperimentChangeType,
paddedVersionString,
} from "./util";
export { EVENT_EXPERIMENT_VIEWED, EVENT_FEATURE_EVALUATED } from "./core";

View File

@@ -0,0 +1,308 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SavedGroupsValues } from "./types/growthbook";
import {
ConditionInterface,
TestedObj,
ConditionValue,
Operator,
OperatorConditionValue,
VarType,
} from "./types/mongrule";
import { paddedVersionString } from "./util";
const _regexCache: { [key: string]: RegExp } = {};
// The top-level condition evaluation function
export function evalCondition(
obj: TestedObj,
condition: ConditionInterface,
// Must be included for `condition` to correctly evaluate group Operators
savedGroups?: SavedGroupsValues,
): boolean {
savedGroups = savedGroups || {};
// Condition is an object, keys are either specific operators or object paths
// values are either arguments for operators or conditions for paths
for (const [k, v] of Object.entries(condition)) {
switch (k) {
case "$or":
if (!evalOr(obj, v as ConditionInterface[], savedGroups)) return false;
break;
case "$nor":
if (evalOr(obj, v as ConditionInterface[], savedGroups)) return false;
break;
case "$and":
if (!evalAnd(obj, v as ConditionInterface[], savedGroups)) return false;
break;
case "$not":
if (evalCondition(obj, v as ConditionInterface, savedGroups))
return false;
break;
default:
if (!evalConditionValue(v, getPath(obj, k), savedGroups)) return false;
}
}
return true;
}
// Return value at dot-separated path of an object
function getPath(obj: TestedObj, path: string) {
const parts = path.split(".");
let current: any = obj;
for (let i = 0; i < parts.length; i++) {
if (current && typeof current === "object" && parts[i] in current) {
current = current[parts[i]];
} else {
return null;
}
}
return current;
}
// Transform a regex string into a real RegExp object
function getRegex(regex: string, insensitive = false): RegExp {
const cacheKey = `${regex}${insensitive ? "/i" : ""}`;
if (!_regexCache[cacheKey]) {
_regexCache[cacheKey] = new RegExp(
regex.replace(/([^\\])\//g, "$1\\/"),
insensitive ? "i" : undefined,
);
}
return _regexCache[cacheKey];
}
// Evaluate a single value against a condition
function evalConditionValue(
condition: ConditionValue,
value: any,
savedGroups: SavedGroupsValues,
insensitive: boolean = false,
) {
// Simple equality comparisons
if (typeof condition === "string") {
if (insensitive) {
return String(value).toLowerCase() === condition.toLowerCase();
}
return value + "" === condition;
}
if (typeof condition === "number") {
return value * 1 === condition;
}
if (typeof condition === "boolean") {
return value !== null && !!value === condition;
}
if (condition === null) {
return value === null;
}
if (Array.isArray(condition) || !isOperatorObject(condition)) {
return JSON.stringify(value) === JSON.stringify(condition);
}
// This is a special operator condition and we should evaluate each one separately
for (const op in condition) {
if (
!evalOperatorCondition(
op as Operator,
value,
condition[op as keyof OperatorConditionValue],
savedGroups,
)
) {
return false;
}
}
return true;
}
// If the object has only keys that start with '$'
function isOperatorObject(obj: any): boolean {
const keys = Object.keys(obj);
return (
keys.length > 0 && keys.filter((k) => k[0] === "$").length === keys.length
);
}
// Return the data type of a value
function getType(v: any): VarType | "unknown" {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
const t = typeof v;
if (["string", "number", "boolean", "object", "undefined"].includes(t)) {
return t as VarType;
}
return "unknown";
}
// At least one element of actual must match the expected condition/value
function elemMatch(actual: any, expected: any, savedGroups: SavedGroupsValues) {
if (!Array.isArray(actual)) return false;
const check = isOperatorObject(expected)
? (v: any) => evalConditionValue(expected, v, savedGroups)
: (v: any) => evalCondition(v, expected, savedGroups);
for (let i = 0; i < actual.length; i++) {
if (actual[i] && check(actual[i])) {
return true;
}
}
return false;
}
function isIn(
actual: any,
expected: Array<any>,
insensitive: boolean = false,
): boolean {
if (insensitive) {
const caseFold = (val: any) =>
typeof val === "string" ? val.toLowerCase() : val;
// Do an intersection if attribute is an array (insensitive)
if (Array.isArray(actual)) {
return actual.some((el) =>
expected.some((exp) => caseFold(el) === caseFold(exp)),
);
}
return expected.some((exp) => caseFold(actual) === caseFold(exp));
}
// Do an intersection if attribute is an array
if (Array.isArray(actual)) {
return actual.some((el) => expected.includes(el));
}
return expected.includes(actual);
}
function isInAll(
actual: any,
expected: ConditionValue[],
savedGroups: SavedGroupsValues,
insensitive: boolean = false,
): boolean {
if (!Array.isArray(actual)) return false;
for (let i = 0; i < expected.length; i++) {
let passed = false;
for (let j = 0; j < actual.length; j++) {
if (
evalConditionValue(expected[i], actual[j], savedGroups, insensitive)
) {
passed = true;
break;
}
}
if (!passed) return false;
}
return true;
}
// Evaluate a single operator condition
function evalOperatorCondition(
operator: Operator,
actual: any,
expected: any,
savedGroups: SavedGroupsValues,
): boolean {
switch (operator) {
case "$veq":
return paddedVersionString(actual) === paddedVersionString(expected);
case "$vne":
return paddedVersionString(actual) !== paddedVersionString(expected);
case "$vgt":
return paddedVersionString(actual) > paddedVersionString(expected);
case "$vgte":
return paddedVersionString(actual) >= paddedVersionString(expected);
case "$vlt":
return paddedVersionString(actual) < paddedVersionString(expected);
case "$vlte":
return paddedVersionString(actual) <= paddedVersionString(expected);
case "$eq":
return actual === expected;
case "$ne":
return actual !== expected;
case "$lt":
return actual < expected;
case "$lte":
return actual <= expected;
case "$gt":
return actual > expected;
case "$gte":
return actual >= expected;
case "$exists":
// Using `!=` and `==` instead of strict checks so it also matches for undefined
return expected ? actual != null : actual == null;
case "$in":
if (!Array.isArray(expected)) return false;
return isIn(actual, expected);
case "$ini":
if (!Array.isArray(expected)) return false;
return isIn(actual, expected, true);
case "$inGroup":
return isIn(actual, savedGroups[expected] || []);
case "$notInGroup":
return !isIn(actual, savedGroups[expected] || []);
case "$nin":
if (!Array.isArray(expected)) return false;
return !isIn(actual, expected);
case "$nini":
if (!Array.isArray(expected)) return false;
return !isIn(actual, expected, true);
case "$not":
return !evalConditionValue(expected, actual, savedGroups);
case "$size":
if (!Array.isArray(actual)) return false;
return evalConditionValue(expected, actual.length, savedGroups);
case "$elemMatch":
return elemMatch(actual, expected, savedGroups);
case "$all":
if (!Array.isArray(expected)) return false;
return isInAll(actual, expected, savedGroups);
case "$alli":
if (!Array.isArray(expected)) return false;
return isInAll(actual, expected, savedGroups, true);
case "$regex":
try {
return getRegex(expected).test(actual);
} catch (e) {
return false;
}
case "$regexi":
try {
return getRegex(expected, true).test(actual);
} catch (e) {
return false;
}
case "$type":
return getType(actual) === expected;
default:
console.error("Unknown operator: " + operator);
return false;
}
}
// Recursive $or rule
function evalOr(
obj: TestedObj,
conditions: ConditionInterface[],
savedGroups: SavedGroupsValues,
): boolean {
if (!conditions.length) return true;
for (let i = 0; i < conditions.length; i++) {
if (evalCondition(obj, conditions[i], savedGroups)) {
return true;
}
}
return false;
}
// Recursive $and rule
function evalAnd(
obj: TestedObj,
conditions: ConditionInterface[],
savedGroups: SavedGroupsValues,
): boolean {
for (let i = 0; i < conditions.length; i++) {
if (!evalCondition(obj, conditions[i], savedGroups)) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,232 @@
import type { GrowthBook } from "../GrowthBook";
import type {
UserScopedGrowthBook,
GrowthBookClient,
} from "../GrowthBookClient";
export type AutoAttributeSettings = {
uuidCookieName?: string;
uuidKey?: string;
uuid?: string;
uuidAutoPersist?: boolean;
};
function getBrowserDevice(ua: string): { browser: string; deviceType: string } {
const browser = ua.match(/Edg/)
? "edge"
: ua.match(/Chrome/)
? "chrome"
: ua.match(/Firefox/)
? "firefox"
: ua.match(/Safari/)
? "safari"
: "unknown";
const deviceType = ua.match(/Mobi/) ? "mobile" : "desktop";
return { browser, deviceType };
}
function getURLAttributes(url: URL | Location | undefined) {
if (!url) return {};
return {
url: url.href,
path: url.pathname,
host: url.host,
query: url.search,
};
}
export function autoAttributesPlugin(settings: AutoAttributeSettings = {}) {
// Browser only
if (typeof window === "undefined") {
throw new Error("autoAttributesPlugin only works in the browser");
}
const COOKIE_NAME = settings.uuidCookieName || "gbuuid";
const uuidKey = settings.uuidKey || "id";
let uuid = settings.uuid || "";
function persistUUID() {
setCookie(COOKIE_NAME, uuid);
}
function getUUID() {
// Already stored in memory, return
if (uuid) return uuid;
// If cookie is already set, return
uuid = getCookie(COOKIE_NAME);
if (uuid) return uuid;
// Generate a new UUID
uuid = genUUID(window.crypto);
return uuid;
}
// Listen for a custom event to persist the UUID cookie
document.addEventListener("growthbookpersist", () => {
persistUUID();
});
function getAutoAttributes(settings: AutoAttributeSettings) {
const ua = navigator.userAgent;
const _uuid = getUUID();
// If a uuid is provided, default persist to false, otherwise default to true
if (settings.uuidAutoPersist ?? !settings.uuid) {
persistUUID();
}
const url = location;
return {
...getDataLayerVariables(),
[uuidKey]: _uuid,
...getURLAttributes(url),
pageTitle: document.title,
...getBrowserDevice(ua),
...getUtmAttributes(url),
};
}
return (gb: GrowthBook | UserScopedGrowthBook | GrowthBookClient) => {
// Only works for instances with user attributes
if ("createScopedInstance" in gb) {
return;
}
// Set initial attributes
const attributes = getAutoAttributes(settings);
attributes.url && gb.setURL(attributes.url);
gb.updateAttributes(attributes);
// Poll for URL changes and update GrowthBook
let currentUrl = attributes.url;
const intervalTimer = setInterval(() => {
if (location.href !== currentUrl) {
currentUrl = location.href;
gb.setURL(currentUrl);
gb.updateAttributes(getAutoAttributes(settings));
}
}, 500);
// Listen for a custom event to update URL and attributes
const refreshListener = () => {
if (location.href !== currentUrl) {
currentUrl = location.href;
gb.setURL(currentUrl);
}
gb.updateAttributes(getAutoAttributes(settings));
};
document.addEventListener("growthbookrefresh", refreshListener);
if ("onDestroy" in gb) {
gb.onDestroy(() => {
clearInterval(intervalTimer);
document.removeEventListener("growthbookrefresh", refreshListener);
});
}
};
}
function setCookie(name: string, value: string) {
const d = new Date();
const COOKIE_DAYS = 400; // 400 days is the max cookie duration for chrome
d.setTime(d.getTime() + 24 * 60 * 60 * 1000 * COOKIE_DAYS);
document.cookie = name + "=" + value + ";path=/;expires=" + d.toUTCString();
}
function getCookie(name: string): string {
const value = "; " + document.cookie;
const parts = value.split(`; ${name}=`);
return parts.length === 2 ? parts[1].split(";")[0] : "";
}
// Use the browsers crypto.randomUUID if set to generate a UUID
function genUUID(crypto?: Crypto) {
if (crypto && crypto.randomUUID) return crypto.randomUUID();
return ("" + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => {
const n =
crypto && crypto.getRandomValues
? crypto.getRandomValues(new Uint8Array(1))[0]
: Math.floor(Math.random() * 256);
return (
(c as unknown as number) ^
(n & (15 >> ((c as unknown as number) / 4)))
).toString(16);
});
}
function getUtmAttributes(url: URL | Location | undefined) {
// Store utm- params in sessionStorage for future page loads
let utms: Record<string, string> = {};
try {
const existing = sessionStorage.getItem("utm_params");
if (existing) {
utms = JSON.parse(existing);
}
} catch (e) {
// Do nothing if sessionStorage is disabled (e.g. incognito window)
}
// Add utm params from querystring
if (url && url.search) {
const params = new URLSearchParams(url.search);
let hasChanges = false;
["source", "medium", "campaign", "term", "content"].forEach((k) => {
// Querystring is in snake_case
const param = `utm_${k}`;
// Attribute keys are camelCase
const attr = `utm` + k[0].toUpperCase() + k.slice(1);
if (params.has(param)) {
utms[attr] = params.get(param) || "";
hasChanges = true;
}
});
// Write back to sessionStorage
if (hasChanges) {
try {
sessionStorage.setItem("utm_params", JSON.stringify(utms));
} catch (e) {
// Do nothing if sessionStorage is disabled (e.g. incognito window)
}
}
}
return utms;
}
function getDataLayerVariables() {
if (
typeof window === "undefined" ||
!window.dataLayer ||
!window.dataLayer.forEach
) {
return {};
}
const obj: Record<string, unknown> = {};
window.dataLayer.forEach((item: unknown) => {
// Skip empty and non-object entries
if (!item || typeof item !== "object" || "length" in item) return;
// Skip events
if ("event" in item) return;
Object.keys(item).forEach((k) => {
// Filter out known properties that aren't useful
if (typeof k !== "string" || k.match(/^(gtm)/)) return;
const val = (item as Record<string, unknown>)[k];
// Only add primitive variable values
const valueType = typeof val;
if (["string", "number", "boolean"].includes(valueType)) {
obj[k] = val;
}
});
});
return obj;
}

View File

@@ -0,0 +1,244 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { GrowthBook } from "../GrowthBook";
import {
Attributes,
FeatureApiResponse,
LogUnion,
Plugin,
} from "../types/growthbook";
import { GrowthBookClient, UserScopedGrowthBook } from "../GrowthBookClient";
export type DevtoolsState = {
attributes?: Record<string, any>;
features?: Record<string, any>;
experiments?: Record<string, number>;
};
export interface NextjsReadonlyRequestCookiesCompat {
get: (name: string) => { name: string; value: string } | undefined;
}
export interface NextjsRequestCompat {
nextUrl: {
searchParams: URLSearchParams;
};
cookies: {
get: (name: string) => { name: string; value: string } | undefined;
};
}
export interface ExpressRequestCompat {
cookies: Record<string, string | string[]>;
query: Record<string, string>;
[key: string]: unknown;
}
function applyDevtoolsState(
devtoolsState: DevtoolsState,
gb: GrowthBook | UserScopedGrowthBook,
) {
// Only enable in dev mode
if (!gb.inDevMode()) {
return;
}
if (
devtoolsState.attributes &&
typeof devtoolsState.attributes === "object"
) {
gb.setAttributeOverrides(devtoolsState.attributes);
}
if (devtoolsState.features && typeof devtoolsState.features === "object") {
const map = new Map(Object.entries(devtoolsState.features));
gb.setForcedFeatures(map);
}
if (
devtoolsState.experiments &&
typeof devtoolsState.experiments === "object"
) {
gb.setForcedVariations(devtoolsState.experiments);
}
}
export function devtoolsPlugin(devtoolsState?: DevtoolsState): Plugin {
return (gb: GrowthBook | UserScopedGrowthBook | GrowthBookClient) => {
// Only works for user-scoped GrowthBook instances
if ("createScopedInstance" in gb) {
throw new Error(
"devtoolsPlugin can only be set on a user-scoped instance",
);
}
if (devtoolsState) {
applyDevtoolsState(devtoolsState, gb);
}
};
}
/**
* For NextJS environments.
* When using server components, use the `searchParams` and `requestCookies` fields.
* - Note: In NextJS 15+, you should await these values before passing them to the plugin
* When using middleware / api routes, provide the `request` field instead.
*/
export function devtoolsNextjsPlugin({
searchParams,
requestCookies,
request,
}: {
searchParams?: { _gbdebug?: string };
requestCookies?: NextjsReadonlyRequestCookiesCompat;
request?: NextjsRequestCompat;
}): Plugin {
function extractGbDebugPayload({
searchParams,
requestCookies,
}: {
searchParams?: { _gbdebug?: string } | URLSearchParams;
requestCookies?: NextjsReadonlyRequestCookiesCompat;
}): string | undefined {
if (searchParams) {
if ("_gbdebug" in searchParams) {
return searchParams._gbdebug;
}
if (searchParams instanceof URLSearchParams) {
return searchParams.get("_gbdebug") ?? undefined;
}
}
return requestCookies?.get("_gbdebug")?.value;
}
return (gb: GrowthBook | UserScopedGrowthBook | GrowthBookClient) => {
let payload = extractGbDebugPayload({ searchParams, requestCookies });
if (!payload && request) {
payload = extractGbDebugPayload({
searchParams: request.nextUrl.searchParams,
requestCookies: request.cookies,
});
}
let state: DevtoolsState = {};
if (payload) {
try {
state = JSON.parse(payload);
} catch (e) {
console.error("cannot parse devtools payload", e);
}
}
devtoolsPlugin(state)(gb);
};
}
/**
* Intended to be used with cookieParser() middleware from npm: 'cookie-parser'.
*/
export function devtoolsExpressPlugin({
request,
}: {
request?: ExpressRequestCompat;
}): Plugin {
return (gb: GrowthBook | UserScopedGrowthBook | GrowthBookClient) => {
let payload =
typeof request?.query?.["_gbdebug"] === "string"
? request.query["_gbdebug"]
: undefined;
if (!payload) {
payload =
typeof request?.cookies?.["_gbdebug"] === "string"
? request.cookies["_gbdebug"]
: undefined;
}
let state: DevtoolsState = {};
if (payload) {
try {
state = JSON.parse(payload);
} catch (e) {
console.error("cannot parse devtools payload", e);
}
}
devtoolsPlugin(state)(gb);
};
}
export type SdkInfo = {
apiHost: string;
clientKey: string;
source?: string;
version?: string;
payload?: FeatureApiResponse;
attributes?: Attributes;
};
export type LogEvent = {
logs: LogUnion[];
sdkInfo?: SdkInfo;
};
/**
* Helper method to get debug script contents for DevTools
* @param gb - GrowthBook instance. DevMode must be enabled to view log events.
* @param {string} [source] - Label these events for ease of reading in DevTools
* @example
* A React logger component (implement yourself):
```
return (
<script dangerouslySetInnerHTML={{
__html: getDebugScriptContents(gb, "nextjs")
}} />
);
```
*/
export function getDebugScriptContents(
gb: GrowthBook,
source?: string,
): string {
const event = getDebugEvent(gb, source);
if (!event) return "";
return `(window._gbdebugEvents = (window._gbdebugEvents || [])).push(${JSON.stringify(
event,
)});`;
}
export function getDebugEvent(
gb: GrowthBook | UserScopedGrowthBook,
source?: string,
): LogEvent | null {
if (!("logs" in gb)) return null;
// Only enable in dev mode
if (!gb.inDevMode()) {
return null;
}
if (gb instanceof GrowthBook) {
// GrowthBook SDK
const [apiHost, clientKey] = gb.getApiInfo();
return {
logs: gb.logs,
sdkInfo: {
apiHost,
clientKey,
source,
version: gb.version,
payload: gb.getDecryptedPayload(),
attributes: gb.getAttributes(),
},
};
} else if (gb instanceof UserScopedGrowthBook) {
// UserScopedGrowthBook SDK
const userContext = gb.getUserContext();
const [apiHost, clientKey] = gb.getApiInfo();
return {
logs: gb.logs,
sdkInfo: {
apiHost,
clientKey,
source,
version: gb.getVersion(),
payload: gb.getDecryptedPayload(),
attributes: {
...userContext.attributes,
...userContext.attributeOverrides,
},
},
};
}
return null;
}

View File

@@ -0,0 +1,301 @@
import { loadSDKVersion } from "../util";
import type { Attributes, EventProperties } from "../types/growthbook";
import type { GrowthBook } from "../GrowthBook";
import type {
GrowthBookClient,
UserScopedGrowthBook,
} from "../GrowthBookClient";
import { EVENT_EXPERIMENT_VIEWED, EVENT_FEATURE_EVALUATED } from "../core";
const SDK_VERSION = loadSDKVersion();
type GlobalTrackedEvent = {
eventName: string;
properties: Record<string, unknown>;
};
declare global {
interface Window {
gbEvents?:
| (GlobalTrackedEvent | string)[]
| {
push: (event: GlobalTrackedEvent | string) => void;
};
}
}
type EventPayload = {
event_name: string;
properties_json: Record<string, unknown>;
sdk_language: string;
sdk_version: string;
url: string;
context_json: Record<string, unknown>;
user_id: string | null;
device_id: string | null;
page_id: string | null;
session_id: string | null;
page_title?: string;
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_term?: string;
utm_content?: string;
};
function parseString(value: unknown): null | string {
return typeof value === "string" ? value : null;
}
function parseAttributes(attributes: Attributes): {
nested: Attributes;
topLevel: {
user_id: string | null;
device_id: string | null;
page_id: string | null;
session_id: string | null;
page_title?: string;
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_term?: string;
utm_content?: string;
};
} {
const {
user_id,
device_id,
anonymous_id,
id,
page_id,
session_id,
utmCampaign,
utmContent,
utmMedium,
utmSource,
utmTerm,
pageTitle,
...nested
} = attributes;
return {
nested,
topLevel: {
user_id: parseString(user_id),
device_id: parseString(device_id || anonymous_id || id),
page_id: parseString(page_id),
session_id: parseString(session_id),
utm_campaign: parseString(utmCampaign) || undefined,
utm_content: parseString(utmContent) || undefined,
utm_medium: parseString(utmMedium) || undefined,
utm_source: parseString(utmSource) || undefined,
utm_term: parseString(utmTerm) || undefined,
page_title: parseString(pageTitle) || undefined,
},
};
}
type EventData = {
eventName: string;
properties: EventProperties;
attributes: Attributes;
url: string;
};
function getEventPayload({
eventName,
properties,
attributes,
url,
}: EventData): EventPayload {
const { nested, topLevel } = parseAttributes(attributes || {});
return {
event_name: eventName,
properties_json: properties || {},
...topLevel,
sdk_language: "js",
sdk_version: SDK_VERSION,
url: url,
context_json: nested,
};
}
async function track({
clientKey,
ingestorHost,
events,
}: {
events: EventPayload[];
clientKey: string;
ingestorHost?: string;
}) {
if (!events.length) return;
const endpoint = `${
ingestorHost || "https://us1.gb-ingest.com"
}/track?client_key=${clientKey}`;
const body = JSON.stringify(events);
try {
await fetch(endpoint, {
method: "POST",
body,
headers: {
Accept: "application/json",
"Content-Type": "text/plain",
},
credentials: "omit",
});
} catch (e) {
console.error("Failed to track event", e);
}
}
export function growthbookTrackingPlugin({
queueFlushInterval = 100,
ingestorHost,
enable = true,
debug,
dedupeCacheSize = 1000,
dedupeKeyAttributes = [],
eventFilter,
}: {
// TODO: add option to allow filtering out certain attributes that contain PII
queueFlushInterval?: number;
ingestorHost?: string;
enable?: boolean;
debug?: boolean;
dedupeCacheSize?: number;
dedupeKeyAttributes?: string[];
eventFilter?: (event: EventData) => boolean;
} = {}) {
return (gb: GrowthBook | UserScopedGrowthBook | GrowthBookClient) => {
const clientKey = gb.getClientKey();
if (!clientKey) {
throw new Error("clientKey must be specified to use event logging");
}
// LRU cache for events to avoid duplicates
const eventCache = new Set<string>();
if ("setEventLogger" in gb) {
let _q: EventPayload[] = [];
let timer: NodeJS.Timeout | null = null;
const flush = async () => {
const events = _q;
_q = [];
timer && clearTimeout(timer);
timer = null;
events.length && (await track({ clientKey, events, ingestorHost }));
};
let promise: Promise<void> | null = null;
gb.setEventLogger(async (eventName, properties, userContext) => {
const data: EventData = {
eventName,
properties,
attributes: userContext.attributes || {},
url: userContext.url || "",
};
// Skip logging if the event is being filtered
if (eventFilter && !eventFilter(data)) {
return;
}
// De-dupe Feature Evaluated and Experiment Viewed events
if (
eventName === EVENT_FEATURE_EVALUATED ||
eventName === EVENT_EXPERIMENT_VIEWED
) {
// Build the key for de-duping
const dedupeKeyData: Record<string, unknown> = {
eventName,
properties,
};
for (const key of dedupeKeyAttributes) {
dedupeKeyData["attr:" + key] = data.attributes[key];
}
const k = JSON.stringify(dedupeKeyData);
// Duplicate event fired recently, move to end of LRU cache and skip
if (eventCache.has(k)) {
eventCache.delete(k);
eventCache.add(k);
return;
}
eventCache.add(k);
// If the cache is too big, remove the oldest item
if (eventCache.size > dedupeCacheSize) {
const oldest = eventCache.values().next().value;
oldest && eventCache.delete(oldest);
}
}
const payload = getEventPayload(data);
debug &&
console.log(
"Logging event to GrowthBook",
JSON.parse(JSON.stringify(payload)),
);
if (!enable) return;
_q.push(payload);
// Only one in-progress promise at a time
if (!promise) {
promise = new Promise((resolve, reject) => {
// Flush the queue after a delay
timer = setTimeout(() => {
flush().then(resolve).catch(reject);
promise = null;
}, queueFlushInterval);
});
}
await promise;
});
// Flush the queue on page unload
if (typeof document !== "undefined" && document.visibilityState) {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
flush().catch(console.error);
}
});
}
// Flush the queue when the growthbook instance is destroyed
"onDestroy" in gb &&
gb.onDestroy(() => {
flush().catch(console.error);
});
}
// Listen on window.gbEvents.push if in a browser
// This makes it easier to integrate with Segment, GTM, etc.
if (typeof window !== "undefined" && !("createScopedInstance" in gb)) {
const prevEvents = Array.isArray(window.gbEvents) ? window.gbEvents : [];
window.gbEvents = {
push: (event: GlobalTrackedEvent | string) => {
if ("isDestroyed" in gb && gb.isDestroyed()) {
// If trying to log and the instance has been destroyed, switch back to just an array
// This will let the next GrowthBook instance pick it up
window.gbEvents = [event];
return;
}
if (typeof event === "string") {
gb.logEvent(event);
} else if (event) {
gb.logEvent(event.eventName, event.properties);
}
},
};
for (const event of prevEvents) {
window.gbEvents.push(event);
}
}
};
}

View File

@@ -0,0 +1,20 @@
export { autoAttributesPlugin } from "./auto-attributes";
export { growthbookTrackingPlugin } from "./growthbook-tracking";
export { thirdPartyTrackingPlugin } from "./third-party-tracking";
export {
devtoolsPlugin,
devtoolsNextjsPlugin,
devtoolsExpressPlugin,
getDebugScriptContents,
getDebugEvent,
} from "./devtools";
// Types must be exported separately, otherwise rollup includes them in the javascript output which breaks things
export type {
DevtoolsState,
ExpressRequestCompat,
NextjsReadonlyRequestCookiesCompat,
NextjsRequestCompat,
LogEvent,
SdkInfo,
} from "./devtools";

View File

@@ -0,0 +1,74 @@
import type { TrackingCallback } from "../types/growthbook";
import type { GrowthBook } from "../GrowthBook";
import type {
GrowthBookClient,
UserScopedGrowthBook,
} from "../GrowthBookClient";
export type Trackers = "gtag" | "gtm" | "segment";
export function thirdPartyTrackingPlugin({
additionalCallback,
trackers = ["gtag", "gtm", "segment"],
}: {
additionalCallback?: TrackingCallback;
trackers?: Trackers[];
} = {}) {
// Browser only
if (typeof window === "undefined") {
throw new Error("thirdPartyTrackingPlugin only works in the browser");
}
return (gb: GrowthBook | UserScopedGrowthBook | GrowthBookClient) => {
gb.setTrackingCallback(async (e, r) => {
const promises: Promise<unknown>[] = [];
const eventParams = { experiment_id: e.key, variation_id: r.key };
if (additionalCallback) {
promises.push(Promise.resolve(additionalCallback(e, r)));
}
// GA4 - gtag
if (trackers.includes("gtag") && window.gtag) {
let gtagResolve;
const gtagPromise = new Promise((resolve) => {
gtagResolve = resolve;
});
promises.push(gtagPromise);
window.gtag("event", "experiment_viewed", {
...eventParams,
event_callback: gtagResolve,
});
}
// GTM - dataLayer
if (trackers.includes("gtm") && window.dataLayer) {
let datalayerResolve;
const datalayerPromise = new Promise((resolve) => {
datalayerResolve = resolve;
});
promises.push(datalayerPromise);
window.dataLayer.push({
event: "experiment_viewed",
...eventParams,
eventCallback: datalayerResolve,
});
}
// Segment - analytics.js
if (
trackers.includes("segment") &&
window.analytics &&
window.analytics.track
) {
window.analytics.track("Experiment Viewed", eventParams);
const segmentPromise = new Promise((resolve) =>
window.setTimeout(resolve, 300),
);
promises.push(segmentPromise);
}
await Promise.all(promises);
});
};
}

View File

@@ -0,0 +1,321 @@
import {
LocalStorageCompat,
StickyAssignmentsDocument,
StickyAttributeKey,
} from "./types/growthbook";
import { toString } from "./util";
import { getStickyBucketAttributeKey } from "./core";
export interface CookieAttributes {
expires?: number | Date | undefined;
path?: string | undefined;
domain?: string | undefined;
secure?: boolean | undefined;
sameSite?: "strict" | "Strict" | "lax" | "Lax" | "none" | "None" | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[property: string]: any;
}
export interface JsCookiesCompat<T = string> {
set(
name: string,
value: string | T,
options?: CookieAttributes,
): string | undefined;
get(name: string): string | T | undefined;
get(): { [key: string]: string };
remove(name: string, options?: CookieAttributes): void;
}
export interface IORedisCompat {
mget(...keys: string[]): Promise<string[]>;
set(key: string, value: string): Promise<string>;
}
export interface RequestCompat {
cookies: Record<string, string>;
[key: string]: unknown;
}
export interface ResponseCompat {
cookie(
name: string,
value: string,
options?: CookieAttributes,
): ResponseCompat;
[key: string]: unknown;
}
/**
* Responsible for reading and writing documents which describe sticky bucket assignments.
*/
export abstract class StickyBucketService {
protected prefix: string;
constructor(opts?: { prefix?: string }) {
opts = opts || {};
this.prefix = opts.prefix || "";
}
abstract getAssignments(
attributeName: string,
attributeValue: string,
): Promise<StickyAssignmentsDocument | null>;
abstract saveAssignments(doc: StickyAssignmentsDocument): Promise<unknown>;
/**
* The SDK calls getAllAssignments to populate sticky buckets. This in turn will
* typically loop through individual getAssignments calls. However, some StickyBucketService
* instances (i.e. Redis) will instead perform a multi-query inside getAllAssignments instead.
*/
async getAllAssignments(
attributes: Record<string, string>,
): Promise<Record<StickyAttributeKey, StickyAssignmentsDocument>> {
const docs: Record<string, StickyAssignmentsDocument> = {};
(
await Promise.all(
Object.entries(attributes).map(([attributeName, attributeValue]) =>
this.getAssignments(attributeName, attributeValue),
),
)
).forEach((doc) => {
if (doc) {
const key = getStickyBucketAttributeKey(
doc.attributeName,
doc.attributeValue,
);
docs[key] = doc;
}
});
return docs;
}
getKey(attributeName: string, attributeValue: string): string {
return `${this.prefix}${attributeName}||${attributeValue}`;
}
}
export abstract class StickyBucketServiceSync extends StickyBucketService {
abstract getAssignmentsSync(
attributeName: string,
attributeValue: string,
): StickyAssignmentsDocument | null;
abstract saveAssignmentsSync(doc: StickyAssignmentsDocument): void;
async getAssignments(attributeName: string, attributeValue: string) {
return this.getAssignmentsSync(attributeName, attributeValue);
}
async saveAssignments(doc: StickyAssignmentsDocument) {
this.saveAssignmentsSync(doc);
}
getAllAssignmentsSync(
attributes: Record<string, string>,
): Record<StickyAttributeKey, StickyAssignmentsDocument> {
const docs: Record<string, StickyAssignmentsDocument> = {};
Object.entries(attributes)
.map(([attributeName, attributeValue]) =>
this.getAssignmentsSync(attributeName, attributeValue),
)
.forEach((doc) => {
if (doc) {
const key = getStickyBucketAttributeKey(
doc.attributeName,
doc.attributeValue,
);
docs[key] = doc;
}
});
return docs;
}
}
export class LocalStorageStickyBucketService extends StickyBucketService {
private localStorage: LocalStorageCompat | undefined;
constructor(opts?: { prefix?: string; localStorage?: LocalStorageCompat }) {
opts = opts || {};
super();
this.prefix = opts.prefix || "gbStickyBuckets__";
try {
this.localStorage = opts.localStorage || globalThis.localStorage;
} catch (e) {
// Ignore localStorage errors
}
}
async getAssignments(attributeName: string, attributeValue: string) {
const key = this.getKey(attributeName, attributeValue);
let doc: StickyAssignmentsDocument | null = null;
if (!this.localStorage) return doc;
try {
const raw = (await this.localStorage.getItem(key)) || "{}";
const data = JSON.parse(raw);
if (data.attributeName && data.attributeValue && data.assignments) {
doc = data;
}
} catch (e) {
// Ignore localStorage errors
}
return doc;
}
async saveAssignments(doc: StickyAssignmentsDocument) {
const key = this.getKey(doc.attributeName, doc.attributeValue);
if (!this.localStorage) return;
try {
await this.localStorage.setItem(key, JSON.stringify(doc));
} catch (e) {
// Ignore localStorage errors
}
}
}
export class ExpressCookieStickyBucketService extends StickyBucketServiceSync {
/**
* Intended to be used with cookieParser() middleware from npm: 'cookie-parser'.
* Assumes:
* - reading a cookie is automatically decoded via decodeURIComponent() or similar
* - writing a cookie name & value must be manually encoded via encodeURIComponent() or similar
* - all cookie bodies are JSON encoded strings and are manually encoded/decoded
*/
private req: RequestCompat;
private res: ResponseCompat;
private cookieAttributes: CookieAttributes;
constructor({
prefix = "gbStickyBuckets__",
req,
res,
cookieAttributes = { maxAge: 180 * 24 * 3600 * 1000 }, // 180 days
}: {
prefix?: string;
req: RequestCompat;
res: ResponseCompat;
cookieAttributes?: CookieAttributes;
}) {
super();
this.prefix = prefix;
this.req = req;
this.res = res;
this.cookieAttributes = cookieAttributes;
}
getAssignmentsSync(attributeName: string, attributeValue: string) {
const key = this.getKey(attributeName, attributeValue);
let doc: StickyAssignmentsDocument | null = null;
if (!this.req) return doc;
try {
const raw = this.req.cookies[key] || "{}";
const data = JSON.parse(raw);
if (data.attributeName && data.attributeValue && data.assignments) {
doc = data;
}
} catch (e) {
// Ignore cookie errors
}
return doc;
}
saveAssignmentsSync(doc: StickyAssignmentsDocument) {
const key = this.getKey(doc.attributeName, doc.attributeValue);
if (!this.res) return;
const str = JSON.stringify(doc);
this.res.cookie(
encodeURIComponent(key),
encodeURIComponent(str),
this.cookieAttributes,
);
}
}
export class BrowserCookieStickyBucketService extends StickyBucketServiceSync {
/**
* Intended to be used with npm: 'js-cookie'.
* Assumes:
* - reading a cookie is automatically decoded via decodeURIComponent() or similar
* - writing a cookie name & value is automatically encoded via encodeURIComponent() or similar
* - all cookie bodies are JSON encoded strings and are manually encoded/decoded
*/
private jsCookie: JsCookiesCompat;
private cookieAttributes: CookieAttributes;
constructor({
prefix = "gbStickyBuckets__",
jsCookie,
cookieAttributes = { expires: 180 }, // 180 days
}: {
prefix?: string;
jsCookie: JsCookiesCompat;
cookieAttributes?: CookieAttributes;
}) {
super();
this.prefix = prefix;
this.jsCookie = jsCookie;
this.cookieAttributes = cookieAttributes;
}
getAssignmentsSync(attributeName: string, attributeValue: string) {
const key = this.getKey(attributeName, attributeValue);
let doc: StickyAssignmentsDocument | null = null;
if (!this.jsCookie) return doc;
try {
const raw = this.jsCookie.get(key);
const data = JSON.parse(raw || "{}");
if (data.attributeName && data.attributeValue && data.assignments) {
doc = data;
}
} catch (e) {
// Ignore cookie errors
}
return doc;
}
async saveAssignmentsSync(doc: StickyAssignmentsDocument) {
const key = this.getKey(doc.attributeName, doc.attributeValue);
if (!this.jsCookie) return;
const str = JSON.stringify(doc);
this.jsCookie.set(key, str, this.cookieAttributes);
}
}
export class RedisStickyBucketService extends StickyBucketService {
/** Intended to be used with npm: 'ioredis'. **/
private redis: IORedisCompat | undefined;
constructor({ redis }: { redis: IORedisCompat }) {
super();
this.redis = redis;
}
async getAllAssignments(
attributes: Record<string, string>,
): Promise<Record<StickyAttributeKey, StickyAssignmentsDocument>> {
const docs: Record<StickyAttributeKey, StickyAssignmentsDocument> = {};
const keys = Object.entries(attributes).map(
([attributeName, attributeValue]) =>
getStickyBucketAttributeKey(attributeName, attributeValue),
);
if (!this.redis) return docs;
await this.redis.mget(...keys).then((values) => {
values.forEach((raw) => {
try {
const data = JSON.parse(raw || "{}");
if (
data.attributeName &&
"attributeValue" in data &&
data.assignments
) {
const key = getStickyBucketAttributeKey(
data.attributeName,
toString(data.attributeValue),
);
docs[key] = data;
}
} catch (e) {
// ignore redis doc parse errors
}
});
});
return docs;
}
async getAssignments(_attributeName: string, _attributeValue: string) {
// not implemented
return null;
}
async saveAssignments(doc: StickyAssignmentsDocument) {
const key = this.getKey(doc.attributeName, doc.attributeValue);
if (!this.redis) return;
await this.redis.set(key, JSON.stringify(doc));
}
}

View File

@@ -0,0 +1,589 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
GrowthBook,
GrowthBookClient,
StickyBucketService,
UserScopedGrowthBook,
} from "..";
import { ConditionInterface, ParentConditionInterface } from "./mongrule";
declare global {
interface Window {
_growthbook?: GrowthBook;
}
}
export type VariationMeta = {
passthrough?: boolean;
key?: string;
name?: string;
};
export type FeatureRule<T = any> = {
id?: string;
condition?: ConditionInterface;
parentConditions?: ParentConditionInterface[];
force?: T;
variations?: T[];
weights?: number[];
key?: string;
hashAttribute?: string;
fallbackAttribute?: string;
hashVersion?: number;
disableStickyBucketing?: boolean;
bucketVersion?: number;
minBucketVersion?: number;
range?: VariationRange;
coverage?: number;
/** @deprecated */
namespace?: [string, number, number];
ranges?: VariationRange[];
meta?: VariationMeta[];
filters?: Filter[];
seed?: string;
name?: string;
phase?: string;
tracks?: Array<{
experiment: Experiment<T>;
result: Result<T>;
}>;
};
export interface FeatureDefinition<T = any> {
defaultValue?: T;
rules?: FeatureRule<T>[];
}
export type FeatureResultSource =
| "unknownFeature"
| "defaultValue"
| "force"
| "override"
| "experiment"
| "prerequisite"
| "cyclicPrerequisite";
export interface FeatureResult<T = any> {
value: T | null;
source: FeatureResultSource;
on: boolean;
off: boolean;
ruleId: string;
experiment?: Experiment<T>;
experimentResult?: Result<T>;
}
/** @deprecated */
export type ExperimentStatus = "draft" | "running" | "stopped";
export type UrlTargetType = "regex" | "simple";
export type UrlTarget = {
include: boolean;
type: UrlTargetType;
pattern: string;
};
export type Experiment<T> = {
key: string;
variations: [T, T, ...T[]];
ranges?: VariationRange[];
meta?: VariationMeta[];
filters?: Filter[];
seed?: string;
name?: string;
phase?: string;
urlPatterns?: UrlTarget[];
weights?: number[];
condition?: ConditionInterface;
parentConditions?: ParentConditionInterface[];
coverage?: number;
include?: () => boolean;
/** @deprecated */
namespace?: [string, number, number];
force?: number;
hashAttribute?: string;
fallbackAttribute?: string;
hashVersion?: number;
disableStickyBucketing?: boolean;
bucketVersion?: number;
minBucketVersion?: number;
active?: boolean;
persistQueryString?: boolean;
/** @deprecated */
status?: ExperimentStatus;
/** @deprecated */
url?: RegExp;
/** @deprecated */
groups?: string[];
};
export type AutoExperimentChangeType = "redirect" | "visual" | "unknown";
export type AutoExperiment<T = AutoExperimentVariation> = Experiment<T> & {
changeId?: string;
// If true, require the experiment to be manually triggered
manual?: boolean;
};
export type ExperimentOverride = {
condition?: ConditionInterface;
weights?: number[];
active?: boolean;
status?: ExperimentStatus;
force?: number;
coverage?: number;
groups?: string[];
namespace?: [string, number, number];
url?: RegExp | string;
};
export interface Result<T> {
value: T;
variationId: number;
key: string;
name?: string;
bucket?: number;
passthrough?: boolean;
inExperiment: boolean;
hashUsed?: boolean;
hashAttribute: string;
hashValue: string;
featureId: string | null;
stickyBucketUsed?: boolean;
}
export type Attributes = Record<string, any>;
export interface TrackingData {
experiment: Experiment<any>;
result: Result<any>;
}
export interface TrackingDataWithUser {
experiment: Experiment<any>;
result: Result<any>;
user: UserContext;
}
export type TrackingCallback = (
experiment: Experiment<any>,
result: Result<any>,
) => Promise<void> | void;
export type TrackingCallbackWithUser = (
experiment: Experiment<any>,
result: Result<any>,
user: UserContext,
) => Promise<void> | void;
export type FeatureUsageCallback = (
key: string,
result: FeatureResult<any>,
) => void;
export type FeatureUsageCallbackWithUser = (
key: string,
result: FeatureResult<any>,
user: UserContext,
) => void;
export type Plugin = (
gb: GrowthBook | UserScopedGrowthBook | GrowthBookClient,
) => void;
export type EventProperties = Record<string, unknown>;
export type EventLogger = (
eventName: string,
properties: EventProperties,
userContext: UserContext,
) => void | Promise<void>;
export type NavigateCallback = (url: string) => void | Promise<void>;
export type ApplyDomChangesCallback = (
changes: AutoExperimentVariation,
) => () => void;
export type RenderFunction = () => void;
// Constructor Options
export type Options = {
enabled?: boolean;
attributes?: Attributes;
url?: string;
features?: Record<string, FeatureDefinition>;
experiments?: AutoExperiment[];
forcedVariations?: Record<string, number>;
forcedFeatureValues?: Map<string, any>;
attributeOverrides?: Attributes;
blockedChangeIds?: string[];
disableVisualExperiments?: boolean;
disableJsInjection?: boolean;
jsInjectionNonce?: string;
disableUrlRedirectExperiments?: boolean;
disableCrossOriginUrlRedirectExperiments?: boolean;
disableExperimentsOnLoad?: boolean;
stickyBucketAssignmentDocs?: Record<
StickyAttributeKey,
StickyAssignmentsDocument
>;
stickyBucketService?: StickyBucketService;
debug?: boolean;
log?: (msg: string, ctx: any) => void;
qaMode?: boolean;
/** @deprecated */
backgroundSync?: boolean;
/** @deprecated */
subscribeToChanges?: boolean;
enableDevMode?: boolean;
disableCache?: boolean;
/** @deprecated */
disableDevTools?: boolean;
trackingCallback?: TrackingCallback;
onFeatureUsage?: FeatureUsageCallback;
eventLogger?: EventLogger;
cacheKeyAttributes?: (keyof Attributes)[];
/** @deprecated */
user?: {
id?: string;
anonId?: string;
[key: string]: string | undefined;
};
/** @deprecated */
overrides?: Record<string, ExperimentOverride>;
/** @deprecated */
groups?: Record<string, boolean>;
apiHost?: string;
streamingHost?: string;
apiHostRequestHeaders?: Record<string, string>;
streamingHostRequestHeaders?: Record<string, string>;
clientKey?: string;
renderer?: null | RenderFunction;
decryptionKey?: string;
remoteEval?: boolean;
navigate?: NavigateCallback;
navigateDelay?: number;
maxNavigateDelay?: number;
/** @deprecated */
antiFlicker?: boolean;
/** @deprecated */
antiFlickerTimeout?: number;
applyDomChangesCallback?: ApplyDomChangesCallback;
savedGroups?: SavedGroupsValues;
plugins?: Plugin[];
};
export type ClientOptions = {
enabled?: boolean;
debug?: boolean;
globalAttributes?: Attributes;
forcedVariations?: Record<string, number>;
forcedFeatureValues?: Map<string, any>;
log?: (msg: string, ctx: any) => void;
qaMode?: boolean;
disableCache?: boolean;
trackingCallback?: TrackingCallbackWithUser;
onFeatureUsage?: (
key: string,
result: FeatureResult<any>,
user: UserContext,
) => void;
eventLogger?: EventLogger;
apiHost?: string;
streamingHost?: string;
apiHostRequestHeaders?: Record<string, string>;
streamingHostRequestHeaders?: Record<string, string>;
clientKey?: string;
decryptionKey?: string;
savedGroups?: SavedGroupsValues;
plugins?: Plugin[];
};
// Contexts
export type GlobalContext = {
log: (msg: string, ctx: any) => void;
features?: FeatureDefinitions;
experiments?: AutoExperiment[];
enabled?: boolean;
qaMode?: boolean;
savedGroups?: SavedGroupsValues;
forcedVariations?: Record<string, number>;
forcedFeatureValues?: Map<string, any>;
trackingCallback?: TrackingCallbackWithUser;
onFeatureUsage?: FeatureUsageCallbackWithUser;
onExperimentEval?: (experiment: Experiment<any>, result: Result<any>) => void;
saveDeferredTrack?: (data: TrackingData) => void;
recordChangeId?: (changeId: string) => void;
eventLogger?: EventLogger;
/** @deprecated */
overrides?: Record<string, ExperimentOverride>;
/** @deprecated */
groups?: Record<string, boolean>;
/** @deprecated */
user?: {
id?: string;
anonId?: string;
[key: string]: string | undefined;
};
};
// Some global fields can be overridden by the user, others are always user-level
export type UserContext = {
enabled?: boolean;
qaMode?: boolean;
enableDevMode?: boolean;
attributes?: Attributes;
url?: string;
blockedChangeIds?: string[];
stickyBucketAssignmentDocs?: Record<
StickyAttributeKey,
StickyAssignmentsDocument
>;
saveStickyBucketAssignmentDoc?: (
doc: StickyAssignmentsDocument,
) => Promise<unknown>;
forcedVariations?: Record<string, number>;
forcedFeatureValues?: Map<string, any>;
attributeOverrides?: Attributes;
trackingCallback?: TrackingCallback;
onFeatureUsage?: FeatureUsageCallback;
trackedExperiments?: Set<string>;
trackedFeatureUsage?: Record<string, string>;
devLogs?: LogUnion[];
};
export type StackContext = {
id?: string;
evaluatedFeatures: Set<string>;
};
export type EvalContext = {
global: GlobalContext;
user: UserContext;
stack: StackContext;
};
export type PrefetchOptions = Pick<
Options,
| "decryptionKey"
| "apiHost"
| "apiHostRequestHeaders"
| "streamingHost"
| "streamingHostRequestHeaders"
> & {
clientKey: string;
streaming?: boolean;
skipCache?: boolean;
};
export type SubscriptionFunction = (
experiment: Experiment<any>,
result: Result<any>,
) => void;
export type VariationRange = [number, number];
export interface InitResponse {
// If a payload was set
success: boolean;
// Where the payload came from, if set
source: "init" | "cache" | "network" | "error" | "timeout";
// If the payload could not be set (success = false), this will hold the fetch error
error?: Error;
}
export interface FetchResponse {
data: FeatureApiResponse | null;
success: boolean;
source: "cache" | "network" | "error" | "timeout";
error?: Error;
}
export type JSONValue =
| null
| number
| string
| boolean
| Array<JSONValue>
| Record<string, unknown>
| { [key: string]: JSONValue };
export type WidenPrimitives<T> = T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: T;
export type DOMMutation = {
selector: string;
action: string;
attribute: string;
value?: string;
parentSelector?: string;
insertBeforeSelector?: string;
};
export type AutoExperimentVariation = {
domMutations?: DOMMutation[];
css?: string;
js?: string;
urlRedirect?: string;
};
export type FeatureDefinitions = Record<string, FeatureDefinition>;
export type FeatureApiResponse = {
features?: FeatureDefinitions;
dateUpdated?: string;
encryptedFeatures?: string;
experiments?: AutoExperiment[];
encryptedExperiments?: string;
savedGroups?: SavedGroupsValues;
encryptedSavedGroups?: string;
};
// Alias
export type GrowthBookPayload = FeatureApiResponse;
// Polyfills required for non-standard browser environments (ReactNative, Node, etc.)
// These are typed as `any` since polyfills like `node-fetch` are not 100% compatible with native types
export type Polyfills = {
fetch: any;
SubtleCrypto: any;
EventSource: any;
localStorage?: LocalStorageCompat;
};
export type Helpers = {
fetchFeaturesCall: ({
host,
clientKey,
headers,
}: {
host: string;
clientKey: string;
headers?: Record<string, string>;
}) => Promise<Response>;
fetchRemoteEvalCall: ({
host,
clientKey,
payload,
headers,
}: {
host: string;
clientKey: string;
payload: any;
headers?: Record<string, string>;
}) => Promise<Response>;
eventSourceCall: ({
host,
clientKey,
headers,
}: {
host: string;
clientKey: string;
headers?: Record<string, string>;
}) => EventSource;
startIdleListener: () => (() => void) | void;
stopIdleListener: () => void;
};
export interface LocalStorageCompat {
getItem(key: string): string | null | Promise<string | null>;
setItem(key: string, value: string): void | Promise<void>;
}
export type CacheSettings = {
backgroundSync: boolean;
cacheKey: string;
staleTTL: number;
maxAge: number;
maxEntries: number;
disableIdleStreams: boolean;
idleStreamInterval: number;
disableCache: boolean;
};
export type ApiHost = string;
export type ClientKey = string;
export type InitOptions = {
timeout?: number;
skipCache?: boolean;
payload?: FeatureApiResponse;
streaming?: boolean;
cacheSettings?: CacheSettings;
};
export type InitSyncOptions = {
payload: FeatureApiResponse;
streaming?: boolean;
};
export type LoadFeaturesOptions = {
/** @deprecated */
autoRefresh?: boolean;
timeout?: number;
skipCache?: boolean;
};
export type RefreshFeaturesOptions = {
timeout?: number;
skipCache?: boolean;
};
export type DestroyOptions = {
destroyAllStreams?: boolean;
};
export interface Filter {
// Override the hashAttribute used for this filter
attribute?: string;
// The hash seed
seed: string;
// The hashing version to use
hashVersion: number;
// Only include these resulting ranges
ranges: VariationRange[];
}
export type StickyAttributeKey = string; // `${attributeName}||${attributeValue}`
export type StickyExperimentKey = string; // `${experimentId}__{version}`
export type StickyAssignments = Record<StickyExperimentKey, string>;
export interface StickyAssignmentsDocument {
attributeName: string;
attributeValue: string;
assignments: StickyAssignments;
}
export type SavedGroupsValues = Record<string, (string | number)[]>;
export type BaseLog = {
timestamp: string;
};
export type EventLog = BaseLog & {
logType: "event";
eventName: string;
properties?: Record<string, unknown>;
};
export type ExperimentLog<T> = BaseLog & {
logType: "experiment";
experiment: Experiment<T>;
result: Result<T>;
};
export type FeatureLog = BaseLog & {
logType: "feature";
featureKey: string;
result: FeatureResult;
};
export type LogUnion = EventLog | ExperimentLog<any> | FeatureLog;

View File

@@ -0,0 +1,102 @@
type OrCondition = {
$or: ConditionInterface[];
};
type NorCondition = {
$nor: ConditionInterface[];
};
type AndCondition = {
$and: ConditionInterface[];
};
type NotCondition = {
$not: ConditionInterface;
};
export type Operator =
| "$in"
| "$ini"
| "$inGroup"
| "$nin"
| "$nini"
| "$notInGroup"
| "$gt"
| "$gte"
| "$lt"
| "$lte"
| "$regex"
| "$regexi"
| "$ne"
| "$eq"
| "$size"
| "$elemMatch"
| "$all"
| "$alli"
| "$not"
| "$type"
| "$exists"
| "$vgt"
| "$vgte"
| "$vlt"
| "$vlte"
| "$vne"
| "$veq";
export type VarType =
| "string"
| "number"
| "boolean"
| "array"
| "object"
| "null"
| "undefined";
export type OperatorConditionValue = {
$in?: (string | number)[];
$ini?: (string | number)[];
$inGroup?: string;
$nin?: (string | number)[];
$nini?: (string | number)[];
$notInGroup?: string;
$gt?: number | string;
$gte?: number | string;
$lt?: number | string;
$lte?: number | string;
$regex?: string;
$regexi?: string;
$ne?: number | string;
$eq?: number | string;
$exists?: boolean;
$all?: ConditionValue[];
$alli?: ConditionValue[];
$size?: number | ConditionValue;
$type?: VarType;
$elemMatch?: ConditionInterface | OperatorConditionValue;
$not?: ConditionValue;
};
export type ConditionValue =
| OperatorConditionValue
| string
| number
| boolean
// eslint-disable-next-line
| Array<any>
// eslint-disable-next-line
| Record<string, any>
| null;
export type OperatorCondition = {
[key: string]: ConditionValue;
};
export type ConditionInterface =
| OrCondition
| NorCondition
| AndCondition
| NotCondition
| OperatorCondition;
export type ParentConditionInterface = {
id: string;
condition: ConditionInterface;
gate?: boolean;
};
// eslint-disable-next-line
export type TestedObj = Record<string, any>;

View File

@@ -0,0 +1,423 @@
import {
AutoExperiment,
AutoExperimentChangeType,
Polyfills,
UrlTarget,
UrlTargetType,
VariationRange,
} from "./types/growthbook";
const polyfills: Polyfills = {
fetch: globalThis.fetch ? globalThis.fetch.bind(globalThis) : undefined,
SubtleCrypto: globalThis.crypto ? globalThis.crypto.subtle : undefined,
EventSource: globalThis.EventSource,
};
export function getPolyfills(): Polyfills {
return polyfills;
}
function hashFnv32a(str: string): number {
let hval = 0x811c9dc5;
const l = str.length;
for (let i = 0; i < l; i++) {
hval ^= str.charCodeAt(i);
hval +=
(hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);
}
return hval >>> 0;
}
export function hash(
seed: string,
value: string,
version: number,
): number | null {
// New unbiased hashing algorithm
if (version === 2) {
return (hashFnv32a(hashFnv32a(seed + value) + "") % 10000) / 10000;
}
// Original biased hashing algorithm (keep for backwards compatibility)
if (version === 1) {
return (hashFnv32a(value + seed) % 1000) / 1000;
}
// Unknown hash version
return null;
}
export function getEqualWeights(n: number): number[] {
if (n <= 0) return [];
return new Array(n).fill(1 / n);
}
export function inRange(n: number, range: VariationRange): boolean {
return n >= range[0] && n < range[1];
}
export function inNamespace(
hashValue: string,
namespace: [string, number, number],
): boolean {
const n = hash("__" + namespace[0], hashValue, 1);
if (n === null) return false;
return n >= namespace[1] && n < namespace[2];
}
export function chooseVariation(n: number, ranges: VariationRange[]): number {
for (let i = 0; i < ranges.length; i++) {
if (inRange(n, ranges[i])) {
return i;
}
}
return -1;
}
export function getUrlRegExp(regexString: string): RegExp | undefined {
try {
const escaped = regexString.replace(/([^\\])\//g, "$1\\/");
return new RegExp(escaped);
} catch (e) {
console.error(e);
return undefined;
}
}
export function isURLTargeted(url: string, targets: UrlTarget[]) {
if (!targets.length) return false;
let hasIncludeRules = false;
let isIncluded = false;
for (let i = 0; i < targets.length; i++) {
const match = _evalURLTarget(url, targets[i].type, targets[i].pattern);
if (targets[i].include === false) {
if (match) return false;
} else {
hasIncludeRules = true;
if (match) isIncluded = true;
}
}
return isIncluded || !hasIncludeRules;
}
function _evalSimpleUrlPart(
actual: string,
pattern: string,
isPath: boolean,
): boolean {
try {
// Escape special regex characters and change wildcard `_____` to `.*`
let escaped = pattern
.replace(/[*.+?^${}()|[\]\\]/g, "\\$&")
.replace(/_____/g, ".*");
if (isPath) {
// When matching pathname, make leading/trailing slashes optional
escaped = "\\/?" + escaped.replace(/(^\/|\/$)/g, "") + "\\/?";
}
const regex = new RegExp("^" + escaped + "$", "i");
return regex.test(actual);
} catch (e) {
return false;
}
}
function _evalSimpleUrlTarget(actual: URL, pattern: string) {
try {
// If a protocol is missing, but a host is specified, add `https://` to the front
// Use "_____" as the wildcard since `*` is not a valid hostname in some browsers
const expected = new URL(
pattern.replace(/^([^:/?]*)\./i, "https://$1.").replace(/\*/g, "_____"),
"https://_____",
);
// Compare each part of the URL separately
const comps: Array<[string, string, boolean]> = [
[actual.host, expected.host, false],
[actual.pathname, expected.pathname, true],
];
// We only want to compare hashes if it's explicitly being targeted
if (expected.hash) {
comps.push([actual.hash, expected.hash, false]);
}
expected.searchParams.forEach((v, k) => {
comps.push([actual.searchParams.get(k) || "", v, false]);
});
// If any comparisons fail, the whole thing fails
return !comps.some(
(data) => !_evalSimpleUrlPart(data[0], data[1], data[2]),
);
} catch (e) {
return false;
}
}
function _evalURLTarget(
url: string,
type: UrlTargetType,
pattern: string,
): boolean {
try {
const parsed = new URL(url, "https://_");
if (type === "regex") {
const regex = getUrlRegExp(pattern);
if (!regex) return false;
return (
regex.test(parsed.href) ||
regex.test(parsed.href.substring(parsed.origin.length))
);
} else if (type === "simple") {
return _evalSimpleUrlTarget(parsed, pattern);
}
return false;
} catch (e) {
return false;
}
}
export function getBucketRanges(
numVariations: number,
coverage: number | undefined,
weights?: number[],
): VariationRange[] {
coverage = coverage === undefined ? 1 : coverage;
// Make sure coverage is within bounds
if (coverage < 0) {
if (process.env.NODE_ENV !== "production") {
console.error("Experiment.coverage must be greater than or equal to 0");
}
coverage = 0;
} else if (coverage > 1) {
if (process.env.NODE_ENV !== "production") {
console.error("Experiment.coverage must be less than or equal to 1");
}
coverage = 1;
}
// Default to equal weights if missing or invalid
const equal = getEqualWeights(numVariations);
weights = weights || equal;
if (weights.length !== numVariations) {
if (process.env.NODE_ENV !== "production") {
console.error(
"Experiment.weights array must be the same length as Experiment.variations",
);
}
weights = equal;
}
// If weights don't add up to 1 (or close to it), default to equal weights
const totalWeight = weights.reduce((w, sum) => sum + w, 0);
if (totalWeight < 0.99 || totalWeight > 1.01) {
if (process.env.NODE_ENV !== "production") {
console.error("Experiment.weights must add up to 1");
}
weights = equal;
}
// Covert weights to ranges
let cumulative = 0;
return weights.map((w) => {
const start = cumulative;
cumulative += w;
return [start, start + (coverage as number) * w];
}) as VariationRange[];
}
export function getQueryStringOverride(
id: string,
url: string,
numVariations: number,
) {
if (!url) {
return null;
}
const search = url.split("?")[1];
if (!search) {
return null;
}
const match = search
.replace(/#.*/, "") // Get rid of anchor
.split("&") // Split into key/value pairs
.map((kv) => kv.split("=", 2))
.filter(([k]) => k === id) // Look for key that matches the experiment id
.map(([, v]) => parseInt(v)); // Parse the value into an integer
if (match.length > 0 && match[0] >= 0 && match[0] < numVariations)
return match[0];
return null;
}
export function isIncluded(include: () => boolean) {
try {
return include();
} catch (e) {
console.error(e);
return false;
}
}
const base64ToBuf = (b: string) =>
Uint8Array.from(atob(b), (c) => c.charCodeAt(0));
export async function decrypt(
encryptedString: string,
decryptionKey?: string,
subtle?: SubtleCrypto,
): Promise<string> {
decryptionKey = decryptionKey || "";
subtle =
subtle ||
(globalThis.crypto && globalThis.crypto.subtle) ||
polyfills.SubtleCrypto;
if (!subtle) {
throw new Error("No SubtleCrypto implementation found");
}
try {
const key = await subtle.importKey(
"raw",
base64ToBuf(decryptionKey),
{ name: "AES-CBC", length: 128 },
true,
["encrypt", "decrypt"],
);
const [iv, cipherText] = encryptedString.split(".");
const plainTextBuffer = await subtle.decrypt(
{ name: "AES-CBC", iv: base64ToBuf(iv) },
key,
base64ToBuf(cipherText),
);
return new TextDecoder().decode(plainTextBuffer);
} catch (e) {
throw new Error("Failed to decrypt");
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function toString(input: any): string {
if (typeof input === "string") return input;
return JSON.stringify(input);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function paddedVersionString(input: any): string {
if (typeof input === "number") {
input = input + "";
}
if (!input || typeof input !== "string") {
input = "0";
}
// Remove build info and leading `v` if any
// Split version into parts (both core version numbers and pre-release tags)
// "v1.2.3-rc.1+build123" -> ["1","2","3","rc","1"]
const parts = (input as string).replace(/(^v|\+.*$)/g, "").split(/[-.]/);
// If it's SemVer without a pre-release, add `~` to the end
// ["1","0","0"] -> ["1","0","0","~"]
// "~" is the largest ASCII character, so this will make "1.0.0" greater than "1.0.0-beta" for example
if (parts.length === 3) {
parts.push("~");
}
// Left pad each numeric part with spaces so string comparisons will work ("9">"10", but " 9"<"10")
// Then, join back together into a single string
return parts
.map((v) => (v.match(/^[0-9]+$/) ? v.padStart(5, " ") : v))
.join("-");
}
export function loadSDKVersion(): string {
let version: string;
try {
// @ts-expect-error right-hand value to be replaced by build with string literal
version = __SDK_VERSION__;
} catch (e) {
version = "";
}
return version;
}
export function mergeQueryStrings(oldUrl: string, newUrl: string): string {
let currUrl: URL;
let redirectUrl: URL;
try {
currUrl = new URL(oldUrl);
redirectUrl = new URL(newUrl);
} catch (e) {
console.error(`Unable to merge query strings: ${e}`);
return newUrl;
}
currUrl.searchParams.forEach((value, key) => {
// skip if search param already exists in redirectUrl
if (redirectUrl.searchParams.has(key)) {
return;
}
redirectUrl.searchParams.set(key, value);
});
return redirectUrl.toString();
}
function isObj(x: unknown): x is Record<string, unknown> {
return typeof x === "object" && x !== null;
}
export function getAutoExperimentChangeType(
exp: AutoExperiment,
): AutoExperimentChangeType {
if (
exp.urlPatterns &&
exp.variations.some(
(variation) => isObj(variation) && "urlRedirect" in variation,
)
) {
return "redirect";
} else if (
exp.variations.some(
(variation) =>
isObj(variation) &&
(variation.domMutations || "js" in variation || "css" in variation),
)
) {
return "visual";
}
return "unknown";
}
// Guarantee the promise always resolves within {timeout} ms
// Resolved value will be `null` when there's an error or it takes too long
// Note: The promise will continue running in the background, even if the timeout is hit
export async function promiseTimeout<T>(
promise: Promise<T>,
timeout?: number,
): Promise<T | null> {
return new Promise((resolve) => {
let resolved = false;
let timer: NodeJS.Timeout | undefined;
const finish = (data?: T) => {
if (resolved) return;
resolved = true;
timer && clearTimeout(timer);
resolve(data || null);
};
if (timeout) {
timer = setTimeout(() => finish(), timeout);
}
promise.then((data) => finish(data)).catch(() => finish());
});
}