better-auth-feature-flags
Version:
Ship features safely with feature flags, A/B testing, and progressive rollouts - Better Auth plugin for modern release management
1,534 lines (1,529 loc) • 50.4 kB
JavaScript
import {
__name
} from "./chunk-SHUYVCID.js";
// src/client/cache.ts
var FlagCache = class {
constructor(options = {}) {
this.cache = /* @__PURE__ */ new Map();
this.storage = null;
this.accessOrder = [];
this.keyPrefix = options.keyPrefix || "ff_";
this.version = options.version || "1";
this.defaultTTL = options.ttl || 6e4;
this.maxEntries = options.maxEntries || 100;
this.include = options.include;
this.exclude = options.exclude;
this.keyPrefix = `${this.keyPrefix}${this.version}_`;
if (typeof globalThis !== "undefined" && options.storage !== "memory") {
try {
const storage = options.storage === "sessionStorage" ? globalThis.sessionStorage : globalThis.localStorage;
if (storage) {
const testKey = `${this.keyPrefix}_test`;
storage.setItem(testKey, "test");
storage.removeItem(testKey);
this.storage = storage;
this.loadFromStorage();
this.cleanOldVersions();
}
} catch {
this.storage = null;
}
}
}
static {
__name(this, "FlagCache");
}
shouldCache(key) {
if (this.exclude?.includes(key)) return false;
if (this.include && !this.include.includes(key)) return false;
return true;
}
cleanOldVersions() {
if (!this.storage) return;
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key.startsWith("ff_") && !key.startsWith(this.keyPrefix)) {
this.storage.removeItem(key);
i--;
}
}
}
loadFromStorage() {
if (!this.storage) return;
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key.startsWith(this.keyPrefix)) {
try {
const data = JSON.parse(this.storage.getItem(key) || "");
if (data && typeof data === "object" && "value" in data) {
const flagKey = key.slice(this.keyPrefix.length);
if (!data.sessionId || data.sessionId === this.currentSessionId) {
this.cache.set(flagKey, data);
this.accessOrder.push(flagKey);
}
}
} catch {
this.storage.removeItem(key);
}
}
}
}
evictLRU() {
if (this.cache.size >= this.maxEntries && this.accessOrder.length > 0) {
const lruKey = this.accessOrder.shift();
if (lruKey) {
this.cache.delete(lruKey);
if (this.storage) this.storage.removeItem(`${this.keyPrefix}${lruKey}`);
}
}
}
updateAccessOrder(key) {
const index = this.accessOrder.indexOf(key);
if (index > -1) this.accessOrder.splice(index, 1);
this.accessOrder.push(key);
}
safeStorageWrite(key, value) {
if (!this.storage) return false;
try {
this.storage.setItem(key, value);
return true;
} catch (e) {
if (e?.name === "QuotaExceededError" || e?.code === 22) {
this.clearOldestStorageEntries(5);
try {
this.storage.setItem(key, value);
return true;
} catch {
console.warn(
"[feature-flags] Storage quota exceeded, using memory only"
);
return false;
}
}
return false;
}
}
clearOldestStorageEntries(count) {
if (!this.storage) return;
const toRemove = this.accessOrder.slice(0, count);
for (const key of toRemove)
this.storage.removeItem(`${this.keyPrefix}${key}`);
}
get(key) {
if (!this.shouldCache(key)) return void 0;
const entry = this.cache.get(key);
if (!entry) return void 0;
if (Date.now() - entry.timestamp > entry.ttl) {
this.delete(key);
return void 0;
}
if (entry.sessionId && entry.sessionId !== this.currentSessionId) {
this.delete(key);
return void 0;
}
this.updateAccessOrder(key);
return entry.value;
}
set(key, value, ttl) {
if (!this.shouldCache(key)) return;
this.evictLRU();
const entry = {
value,
timestamp: Date.now(),
ttl: ttl || this.defaultTTL,
sessionId: this.currentSessionId
};
this.cache.set(key, entry);
this.updateAccessOrder(key);
if (this.storage) {
const storageKey = `${this.keyPrefix}${key}`;
this.safeStorageWrite(storageKey, JSON.stringify(entry));
}
}
delete(key) {
this.cache.delete(key);
const index = this.accessOrder.indexOf(key);
if (index > -1) this.accessOrder.splice(index, 1);
if (this.storage) this.storage.removeItem(`${this.keyPrefix}${key}`);
}
clear() {
this.cache.clear();
this.accessOrder = [];
if (!this.storage) return;
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key.startsWith(this.keyPrefix)) {
this.storage.removeItem(key);
i--;
}
}
}
/** Batch get to reduce repeated lookups */
getMany(keys) {
const results = /* @__PURE__ */ new Map();
for (const key of keys) {
const value = this.get(key);
if (value !== void 0) results.set(key, value);
}
return results;
}
/** Batch set for efficient bulk operations */
setMany(entries, ttl) {
for (const [key, value] of Object.entries(entries))
this.set(key, value, ttl);
}
/** Clear cache on session change for security */
invalidateOnSessionChange(newSessionId) {
const sessionChanged = newSessionId !== this.currentSessionId;
this.currentSessionId = newSessionId;
if (sessionChanged) this.clear();
}
getCurrentSessionId() {
return this.currentSessionId;
}
};
// src/context-sanitizer.ts
var DEFAULT_ALLOWED_FIELDS = /* @__PURE__ */ new Set([
// User attributes
"userId",
"organizationId",
"teamId",
"role",
"plan",
"subscription",
// Device/environment
"device",
"browser",
"os",
"platform",
"version",
"locale",
"timezone",
// Application state
"page",
"route",
"feature",
"experiment",
// Safe business attributes
"country",
"region",
"environment",
"buildVersion"
]);
var FORBIDDEN_FIELDS = /* @__PURE__ */ new Set([
"password",
"token",
"apiKey",
"secret",
"creditCard",
"ssn",
"socialSecurityNumber",
"driverLicense",
"passport",
"bankAccount",
"privateKey",
"sessionToken",
"refreshToken",
"accessToken",
"authToken"
]);
var SENSITIVE_PATTERNS = [
/password/i,
/secret/i,
/token/i,
/key/i,
/credit/i,
/ssn/i,
/bank/i,
/private/i,
/auth/i
];
var ContextSanitizer = class {
static {
__name(this, "ContextSanitizer");
}
constructor(options = {}) {
this.allowedFields = options.allowedFields || DEFAULT_ALLOWED_FIELDS;
this.maxSizeForUrl = options.maxSizeForUrl || 2048;
this.maxSizeForBody = options.maxSizeForBody || 10240;
this.strict = options.strict ?? true;
this.warnOnDrop = options.warnOnDrop ?? process.env.NODE_ENV === "development";
}
/**
* Sanitizes context for URL params with strict size limits.
* @returns JSON string under 2KB or undefined if too large
*/
sanitizeForUrl(context) {
const sanitized = this.sanitize(context);
if (Object.keys(sanitized).length === 0) {
return void 0;
}
const serialized = JSON.stringify(sanitized);
if (serialized.length > this.maxSizeForUrl) {
const essential = this.extractEssentialFields(sanitized);
const essentialSerialized = JSON.stringify(essential);
if (essentialSerialized.length > this.maxSizeForUrl) {
if (this.warnOnDrop) {
console.warn(
`[feature-flags] Context too large for URL (${serialized.length} bytes). Maximum allowed: ${this.maxSizeForUrl} bytes. Consider using fewer fields.`
);
}
return void 0;
}
return essentialSerialized;
}
return serialized;
}
/**
* Sanitizes context for POST body with larger size allowance.
* @returns Sanitized object under 10KB or progressively reduced object
*/
sanitizeForBody(context) {
const sanitized = this.sanitize(context);
if (Object.keys(sanitized).length === 0) {
return void 0;
}
const serialized = JSON.stringify(sanitized);
if (serialized.length > this.maxSizeForBody) {
if (this.warnOnDrop) {
console.warn(
`[feature-flags] Context too large for request (${serialized.length} bytes). Maximum allowed: ${this.maxSizeForBody} bytes. Some fields will be dropped.`
);
}
return this.reduceToSize(sanitized, this.maxSizeForBody);
}
return sanitized;
}
// Core sanitization: PII filtering + type normalization
sanitize(context) {
const result = {};
const droppedFields = [];
for (const [key, value] of Object.entries(context)) {
if (FORBIDDEN_FIELDS.has(key)) {
droppedFields.push(`${key} (forbidden)`);
continue;
}
if (SENSITIVE_PATTERNS.some((pattern) => pattern.test(key))) {
droppedFields.push(`${key} (sensitive pattern)`);
continue;
}
if (this.strict && !this.allowedFields.has(key)) {
droppedFields.push(`${key} (not in allowlist)`);
continue;
}
if (value === null || value === void 0) {
result[key] = value;
} else if (typeof value === "object" && !Array.isArray(value)) {
const sanitizedNested = this.sanitize(value);
if (Object.keys(sanitizedNested).length > 0) {
result[key] = sanitizedNested;
}
} else if (Array.isArray(value)) {
result[key] = value.slice(0, 10);
} else if (typeof value === "string") {
result[key] = value.length > 200 ? value.substring(0, 200) + "..." : value;
} else if (typeof value !== "function" && typeof value !== "symbol") {
result[key] = value;
}
}
if (this.warnOnDrop && droppedFields.length > 0) {
console.warn(
`[feature-flags] Dropped context fields for security: ${droppedFields.join(", ")}`
);
}
return result;
}
// Extracts essential fields for minimal context
extractEssentialFields(context) {
const essentialKeys = [
"userId",
"organizationId",
"role",
"plan",
"device",
"environment"
];
const result = {};
for (const key of essentialKeys) {
if (key in context) {
result[key] = context[key];
}
}
return result;
}
// Progressively removes fields until size acceptable
reduceToSize(context, maxSize) {
const entries = Object.entries(context);
let result = { ...context };
entries.sort((a, b) => a[0].length - b[0].length);
for (let i = entries.length - 1; i >= 0; i--) {
const serialized = JSON.stringify(result);
if (serialized.length <= maxSize) {
break;
}
const tuple = entries[i];
if (!tuple) continue;
const key = tuple[0];
delete result[key];
}
return result;
}
/**
* Pre-flight validation to detect potential PII before sanitization.
* @returns Array of warning messages for sensitive fields
*/
static validate(context) {
const warnings = [];
const checkObject = /* @__PURE__ */ __name((obj, path = "") => {
for (const [key, value] of Object.entries(obj)) {
const fullPath = path ? `${path}.${key}` : key;
if (FORBIDDEN_FIELDS.has(key)) {
warnings.push(
`Forbidden field "${fullPath}" detected - will be removed`
);
}
if (SENSITIVE_PATTERNS.some((pattern) => pattern.test(key))) {
warnings.push(
`Potentially sensitive field "${fullPath}" detected - will be removed`
);
}
if (value && typeof value === "object") {
checkObject(value, fullPath);
}
}
}, "checkObject");
checkObject(context);
return warnings;
}
};
var defaultSanitizer = new ContextSanitizer();
// src/override-manager.ts
var SecureOverrideManager = class {
constructor(config = {}) {
this.overrides = /* @__PURE__ */ new Map();
this.ttl = config.ttl ?? 36e5;
this.allowInProduction = config.allowInProduction ?? false;
this.persist = config.persist ?? false;
this.storageKey = `${config.keyPrefix ?? "feature-flags"}-overrides`;
this.environment = config.environment ?? this.detectEnvironment();
if (this.persist && this.isOverrideAllowed()) {
this.loadFromStorage();
}
this.startCleanupTimer();
}
static {
__name(this, "SecureOverrideManager");
}
/**
* Multi-indicator environment detection.
* @algorithm NODE_ENV → hostname → build flags
* @see https://12factor.net/config
*/
detectEnvironment() {
if (typeof process !== "undefined" && process.env?.NODE_ENV) {
return process.env.NODE_ENV;
}
if (typeof window !== "undefined") {
const hostname = window.location.hostname;
if (hostname !== "localhost" && !hostname.startsWith("127.") && !hostname.startsWith("192.168.") && !hostname.includes(".local") && !hostname.includes("staging")) {
return "production";
}
if (typeof import.meta?.env?.MODE !== "undefined") {
return import.meta.env.MODE;
}
}
return "development";
}
/** Guards production override access */
isOverrideAllowed() {
if (this.environment === "production" && !this.allowInProduction) {
return false;
}
return true;
}
/**
* Sets flag override with environment protection.
* @security Blocks production unless allowInProduction=true
* @returns true if set, false if blocked
*/
set(flag, value) {
if (!this.isOverrideAllowed()) {
console.warn(
"[feature-flags] Overrides are disabled in production. Set allowInProduction: true to enable (not recommended)."
);
return false;
}
const override = {
value,
expires: Date.now() + this.ttl,
environment: this.environment
};
this.overrides.set(flag, override);
if (this.persist) {
this.saveToStorage();
}
if (this.environment === "development") {
console.debug(
`[feature-flags] Override set: ${flag} = ${JSON.stringify(value)}, expires in ${this.ttl}ms`
);
}
return true;
}
/** Gets override value, auto-expires stale entries */
get(flag) {
if (!this.isOverrideAllowed()) {
return void 0;
}
const override = this.overrides.get(flag);
if (!override) {
return void 0;
}
if (Date.now() > override.expires) {
this.overrides.delete(flag);
if (this.persist) {
this.saveToStorage();
}
return void 0;
}
if (override.environment !== this.environment) {
console.warn(
`[feature-flags] Override "${flag}" was set in ${override.environment} but current is ${this.environment}`
);
}
return override.value;
}
/** Checks override existence without returning value */
has(flag) {
return this.get(flag) !== void 0;
}
/** Clears specific override and syncs storage */
delete(flag) {
this.overrides.delete(flag);
if (this.persist) {
this.saveToStorage();
}
}
/** Clears all overrides and storage */
clear() {
this.overrides.clear();
if (this.persist) {
this.clearStorage();
}
}
/** Returns all non-expired overrides for debugging */
getAll() {
if (!this.isOverrideAllowed()) {
return {};
}
const result = {};
for (const [key, override] of this.overrides) {
if (Date.now() <= override.expires) {
result[key] = override.value;
}
}
return result;
}
/** Loads persisted overrides, skips expired entries */
loadFromStorage() {
if (typeof globalThis.localStorage === "undefined") {
return;
}
try {
const stored = localStorage.getItem(this.storageKey);
if (!stored) {
return;
}
const data = JSON.parse(stored);
const now = Date.now();
for (const [key, override] of Object.entries(data)) {
if (override.expires > now) {
this.overrides.set(key, override);
}
}
} catch (error) {
console.warn(
"[feature-flags] Failed to load overrides from storage:",
error
);
}
}
/** Persists current overrides to localStorage */
saveToStorage() {
if (typeof globalThis.localStorage === "undefined") {
return;
}
try {
const data = {};
for (const [key, override] of this.overrides) {
data[key] = override;
}
localStorage.setItem(this.storageKey, JSON.stringify(data));
} catch (error) {
console.warn(
"[feature-flags] Failed to save overrides to storage:",
error
);
}
}
/** Removes persisted overrides from storage */
clearStorage() {
if (typeof globalThis.localStorage === "undefined") {
return;
}
try {
localStorage.removeItem(this.storageKey);
} catch (error) {
console.warn(
"[feature-flags] Failed to clear overrides from storage:",
error
);
}
}
/** Starts 1-minute cleanup timer for expired overrides */
startCleanupTimer() {
this.cleanupTimer = setInterval(() => {
this.cleanupExpired();
}, 6e4);
if (typeof process !== "undefined" && this.cleanupTimer.unref) {
this.cleanupTimer.unref();
}
}
/** Removes expired overrides and syncs storage */
cleanupExpired() {
const now = Date.now();
let hasChanges = false;
for (const [key, override] of this.overrides) {
if (override.expires <= now) {
this.overrides.delete(key);
hasChanges = true;
}
}
if (hasChanges && this.persist) {
this.saveToStorage();
}
}
/** Cleanup timer and resources */
dispose() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
}
};
// src/polling.ts
var SmartPoller = class {
constructor(baseInterval, task, onError) {
this.baseInterval = baseInterval;
this.task = task;
this.onError = onError;
this.timer = null;
this.consecutiveErrors = 0;
this.maxRetries = 5;
this.currentInterval = baseInterval;
this.maxInterval = Math.min(baseInterval * 10, 3e5);
}
static {
__name(this, "SmartPoller");
}
start() {
this.stop();
this.scheduleNext();
}
stop() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
scheduleNext() {
const jitter = Math.random() * this.currentInterval * 0.25;
const delay = this.currentInterval + jitter;
this.timer = setTimeout(() => {
this.execute();
}, delay);
}
async execute() {
try {
await this.task();
this.consecutiveErrors = 0;
this.currentInterval = this.baseInterval;
} catch (error) {
this.consecutiveErrors++;
if (this.consecutiveErrors <= this.maxRetries) {
this.currentInterval = Math.min(
this.baseInterval * Math.pow(2, this.consecutiveErrors),
this.maxInterval
);
}
this.onError?.(error);
}
this.scheduleNext();
}
/** Forces immediate execution, useful for reconnection scenarios */
async refreshNow() {
this.stop();
await this.execute();
}
};
// src/client.ts
function featureFlagsClient(options = {}) {
const cache = new FlagCache(options.cache);
const overrideManager = new SecureOverrideManager(options.overrides);
const subscribers = /* @__PURE__ */ new Set();
let context = {};
let cachedFlags = {};
let smartPoller = null;
let sessionUnsubscribe = null;
let lastSessionId = void 0;
const sanitizer = new ContextSanitizer({
strict: options.contextSanitization?.strict ?? true,
allowedFields: options.contextSanitization?.allowedFields ? new Set(options.contextSanitization.allowedFields) : void 0,
maxSizeForUrl: options.contextSanitization?.maxUrlSize,
maxSizeForBody: options.contextSanitization?.maxBodySize,
warnOnDrop: options.contextSanitization?.warnOnDrop
});
const sanitizationEnabled = options.contextSanitization?.enabled ?? true;
const notifySubscribers = /* @__PURE__ */ __name((flags) => {
cachedFlags = flags;
subscribers.forEach((callback) => callback(flags));
}, "notifySubscribers");
return {
id: "feature-flags",
$InferServerPlugin: {},
// HTTP methods for feature-flags endpoints - canonical only
pathMethods: {
// Public endpoints
"/feature-flags/evaluate": "POST",
"/feature-flags/evaluate-batch": "POST",
"/feature-flags/bootstrap": "POST",
"/feature-flags/events": "POST",
"/feature-flags/events/batch": "POST",
"/feature-flags/config": "GET",
"/feature-flags/health": "GET",
// Admin endpoints (RESTful)
"/feature-flags/admin/flags": "GET",
"/feature-flags/admin/flags/:id": "GET",
"/feature-flags/admin/flags/:id/enable": "POST",
"/feature-flags/admin/flags/:id/disable": "POST",
"/feature-flags/admin/flags/:flagId/rules": "GET",
"/feature-flags/admin/flags/:flagId/rules/:ruleId": "GET",
"/feature-flags/admin/flags/:flagId/rules/reorder": "POST",
"/feature-flags/admin/flags/:flagId/stats": "GET",
"/feature-flags/admin/overrides": "GET",
"/feature-flags/admin/overrides/:id": "GET",
"/feature-flags/admin/metrics/usage": "GET",
"/feature-flags/admin/audit": "GET",
"/feature-flags/admin/audit/:id": "GET",
"/feature-flags/admin/environments": "GET",
"/feature-flags/admin/environments/:id": "GET",
"/feature-flags/admin/export": "POST"
},
// No atoms exposed currently
getAtoms: /* @__PURE__ */ __name((..._args) => ({}), "getAtoms"),
getActions: /* @__PURE__ */ __name((fetch, $store, _clientOptions) => {
if ($store?.atoms?.session) {
const unsubscribe = $store.atoms.session.subscribe(
(sessionState) => {
const currentSessionId = sessionState?.data?.session?.id;
if (currentSessionId !== lastSessionId) {
lastSessionId = currentSessionId;
cache.invalidateOnSessionChange(currentSessionId);
cachedFlags = {};
if (currentSessionId) {
notifySubscribers({});
}
}
}
);
sessionUnsubscribe = unsubscribe;
}
const handleError = /* @__PURE__ */ __name((error) => {
if (options.debug) {
console.error("[feature-flags]", error);
}
options.onError?.(error);
}, "handleError");
const logEvaluation = /* @__PURE__ */ __name((flag, result) => {
if (options.debug) {
console.log(`[feature-flags] ${flag}:`, result);
}
options.onEvaluation?.(flag, result);
}, "logEvaluation");
const evaluateFlag = /* @__PURE__ */ __name(async (key, evaluateOptions) => {
const overrideValue = overrideManager.get(String(key));
if (overrideValue !== void 0) {
return {
value: overrideValue,
reason: "override"
};
}
const cached = cache.get(String(key));
if (cached !== void 0) {
logEvaluation(String(key), cached);
return cached;
}
try {
const keyStr = String(key);
const requestBody = {
flagKey: keyStr,
context: Object.keys(context).length > 0 ? sanitizationEnabled ? sanitizer.sanitizeForBody(context) : context : void 0,
default: options.defaults?.[key]
};
if (evaluateOptions?.track !== void 0) {
requestBody.track = evaluateOptions.track;
}
if (evaluateOptions?.select !== void 0) {
requestBody.select = evaluateOptions.select;
}
if (evaluateOptions?.debug !== void 0) {
requestBody.debug = evaluateOptions.debug;
}
if (evaluateOptions?.contextInResponse !== void 0) {
requestBody.contextInResponse = evaluateOptions.contextInResponse;
}
const response = await fetch(`/feature-flags/evaluate`, {
method: "POST",
body: requestBody
});
const result = response.data;
cache.set(keyStr, result);
logEvaluation(keyStr, result);
return result;
} catch (error) {
handleError(error);
if (options.defaults?.[key] !== void 0) {
return {
value: options.defaults[key],
reason: "default"
};
}
return {
value: void 0,
reason: "not_found"
};
}
}, "evaluateFlag");
const actions = {
featureFlags: {
async isEnabled(flag, defaultValue = false) {
const result = await evaluateFlag(flag);
const value = result.value ?? defaultValue;
return Boolean(value);
},
async getValue(flag, defaultValue) {
const result = await evaluateFlag(flag);
return result.value ?? defaultValue;
},
async getVariant(flag) {
const result = await evaluateFlag(flag);
return result.variant || null;
},
// Core evaluation methods
async evaluate(flag, opts) {
let defaultValue = void 0;
let environment = void 0;
if (opts && typeof opts === "object" && ("default" in opts || "environment" in opts || "select" in opts || "contextInResponse" in opts || "track" in opts || "debug" in opts || "context" in opts)) {
defaultValue = opts.default;
environment = opts.environment;
}
const originalContext = { ...context };
if (environment) {
const next = { ...context };
next.attributes = { ...next.attributes || {}, environment };
context = next;
}
try {
const result = await evaluateFlag(flag, opts);
return defaultValue !== void 0 && result.value === void 0 ? { ...result, value: defaultValue } : result;
} finally {
context = originalContext;
}
},
async evaluateMany(keys, opts) {
const cachedResults = cache.getMany(keys.map(String));
const results = {};
const uncachedKeys = [];
const defaultsMap = opts?.defaults || {};
for (const key of keys) {
const cached = cachedResults.get(String(key));
if (cached) {
results[key] = cached;
logEvaluation(String(key), cached);
} else {
uncachedKeys.push(String(key));
}
}
if (uncachedKeys.length === 0) {
return results;
}
try {
const originalContext = { ...context };
if (opts?.environment) {
const next = { ...context };
next.attributes = {
...next.attributes || {},
environment: opts.environment
};
context = next;
}
const batchRequestBody = {
flagKeys: uncachedKeys,
defaults: Object.keys(defaultsMap).length > 0 ? Object.fromEntries(
uncachedKeys.filter((k) => defaultsMap[k] !== void 0).map((k) => [k, defaultsMap[k]])
) : void 0,
context: Object.keys(context).length > 0 ? sanitizationEnabled ? sanitizer.sanitizeForBody(context) : context : void 0
// Do not pass select='value' from client to keep return type stable
// environment support via context enrichment below
};
if (opts?.track !== void 0) {
batchRequestBody.track = opts.track;
}
if (opts?.select !== void 0) {
batchRequestBody.select = opts.select;
}
if (opts?.debug !== void 0) {
batchRequestBody.debug = opts.debug;
}
if (opts?.contextInResponse !== void 0) {
batchRequestBody.contextInResponse = opts.contextInResponse;
}
const response = await fetch("/feature-flags/evaluate-batch", {
method: "POST",
body: batchRequestBody
});
context = originalContext;
const data = response.data;
cache.setMany(data.flags);
for (const [key, result] of Object.entries(data.flags)) {
results[key] = result;
logEvaluation(key, result);
}
return results;
} catch (error) {
handleError(error);
for (const key of uncachedKeys) {
results[key] = {
value: defaultsMap[key],
reason: "default"
};
}
return results;
}
},
async bootstrap(options2) {
try {
const bootstrapRequestBody = {
include: options2?.include,
prefix: options2?.prefix,
environment: options2?.environment,
context: Object.keys(context).length > 0 ? sanitizationEnabled ? sanitizer.sanitizeForBody(context) : context : void 0
};
if (options2?.select !== void 0) {
bootstrapRequestBody.select = options2.select;
}
if (options2?.track !== void 0) {
bootstrapRequestBody.track = options2.track;
}
if (options2?.debug !== void 0) {
bootstrapRequestBody.debug = options2.debug;
}
if (options2?.contextInResponse !== void 0) {
bootstrapRequestBody.contextInResponse = options2.contextInResponse;
}
const response = await fetch(`/feature-flags/bootstrap`, {
method: "POST",
body: bootstrapRequestBody
});
const data = response.data;
const flags = {};
for (const [key, result] of Object.entries(data.flags)) {
flags[key] = result.value;
cache.set(key, result);
}
notifySubscribers(flags);
return flags;
} catch (error) {
handleError(error);
return options2?.defaults || {};
}
},
async track(flag, event, value, options2) {
try {
if (options2?.sampleRate !== void 0 && typeof options2.sampleRate === "number") {
if (options2.sampleRate < 0 || options2.sampleRate > 1) {
throw new Error("sampleRate must be between 0 and 1");
}
if (Math.random() > options2.sampleRate) {
if (options2.debug) {
console.log(
`[feature-flags] Event sampled out (rate: ${options2.sampleRate})`
);
}
return {
success: true,
eventId: "sampled_out",
sampled: true
};
}
}
const headers = {};
if (options2?.idempotencyKey) {
headers["Idempotency-Key"] = options2.idempotencyKey;
}
const response = await fetch("/feature-flags/events", {
method: "POST",
headers,
body: {
flagKey: String(flag),
event,
properties: value,
timestamp: options2?.timestamp,
sampleRate: options2?.sampleRate
}
});
return response.data;
} catch (error) {
handleError(error);
return { success: false, eventId: "" };
}
},
async trackBatch(events, options2) {
try {
const filteredEvents = [];
let sampledCount = 0;
for (const eventData of events) {
const eventSampleRate = eventData.sampleRate ?? options2?.sampleRate;
if (eventSampleRate !== void 0 && typeof eventSampleRate === "number") {
if (eventSampleRate < 0 || eventSampleRate > 1) {
continue;
}
if (Math.random() > eventSampleRate) {
sampledCount++;
continue;
}
}
filteredEvents.push(eventData);
}
if (filteredEvents.length === 0) {
return {
success: 0,
failed: 0,
sampled: sampledCount,
batchId: "sampled_out"
};
}
const transformedEvents = filteredEvents.map(
({ flag, event, data, timestamp, sampleRate }) => ({
flagKey: String(flag),
event,
properties: data,
timestamp,
sampleRate
})
);
const response = await fetch("/feature-flags/events/batch", {
method: "POST",
body: {
events: transformedEvents,
sampleRate: options2?.sampleRate,
idempotencyKey: options2?.idempotencyKey
}
});
const result = response.data;
return {
...result,
sampled: sampledCount + (result.sampled || 0)
};
} catch (error) {
handleError(error);
return {
success: 0,
failed: events.length,
sampled: 0,
batchId: options2?.idempotencyKey || ""
};
}
},
setContext(newContext) {
if (options.debug && sanitizationEnabled) {
const warnings = ContextSanitizer.validate(newContext);
if (warnings.length > 0) {
console.warn(
"[feature-flags] Context validation warnings:\n" + warnings.join("\n")
);
}
}
context = { ...context, ...newContext };
cache.clear();
},
getContext() {
return { ...context };
},
async prefetch(flags) {
const uncached = flags.filter(
(key) => cache.get(String(key)) === void 0
);
if (uncached.length > 0) {
await actions.featureFlags.evaluateMany(uncached);
}
},
clearCache() {
cache.clear();
},
setOverride(flag, value) {
const success = overrideManager.set(String(flag), value);
if (success) {
notifySubscribers({ ...cachedFlags, [flag]: value });
}
},
clearOverrides() {
overrideManager.clear();
actions.featureFlags.refresh();
},
async refresh() {
cache.clear();
const flags = await actions.featureFlags.bootstrap();
notifySubscribers(flags);
},
subscribe(callback) {
subscribers.add(callback);
callback(cachedFlags);
return () => {
subscribers.delete(callback);
};
},
// Admin API methods
admin: {
// Flag CRUD operations
flags: {
async list(options2) {
try {
const response = await fetch("/feature-flags/admin/flags", {
method: "GET",
query: options2
});
return response.data;
} catch (error) {
handleError(error);
return { flags: [], page: { limit: 50, hasMore: false } };
}
},
async create(flag) {
try {
const response = await fetch("/feature-flags/admin/flags", {
method: "POST",
body: flag
});
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async get(id) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}`,
{
method: "GET"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async update(id, updates) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}`,
{
method: "PATCH",
body: updates
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async delete(id) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}`,
{
method: "DELETE"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async enable(id) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}/enable`,
{
method: "POST"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async disable(id) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}/disable`,
{
method: "POST"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
}
},
// Rule CRUD operations
rules: {
async list(flagId) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules`,
{
method: "GET"
}
);
return response.data;
} catch (error) {
handleError(error);
return { rules: [] };
}
},
async create(rule) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${rule.flagId}/rules`,
{
method: "POST",
body: rule
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async get(flagId, ruleId) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules/${ruleId}`,
{
method: "GET"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async update(flagId, ruleId, updates) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules/${ruleId}`,
{
method: "PATCH",
body: updates
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async delete(flagId, ruleId) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules/${ruleId}`,
{
method: "DELETE"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async reorder(flagId, ids) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules/reorder`,
{
method: "POST",
body: { ids }
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
}
},
// Override CRUD operations
overrides: {
async list(options2) {
try {
const response = await fetch(
"/feature-flags/admin/overrides",
{
method: "GET",
query: options2
}
);
return response.data;
} catch (error) {
handleError(error);
return { overrides: [], page: { limit: 50, hasMore: false } };
}
},
async create(override) {
try {
const response = await fetch(
"/feature-flags/admin/overrides",
{
method: "POST",
body: override
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async get(id) {
try {
const response = await fetch(
`/feature-flags/admin/overrides/${id}`,
{
method: "GET"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async update(id, updates) {
try {
const response = await fetch(
`/feature-flags/admin/overrides/${id}`,
{
method: "PATCH",
body: updates
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async delete(id) {
try {
const response = await fetch(
`/feature-flags/admin/overrides/${id}`,
{
method: "DELETE"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
}
},
// Analytics
analytics: {
stats: {
async get(flagId, options2 = {}) {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/stats`,
{
method: "GET",
query: options2
}
);
return response.data;
} catch (error) {
handleError(error);
return { stats: {} };
}
}
},
usage: {
async get(options2 = {}) {
try {
const response = await fetch(
"/feature-flags/admin/metrics/usage",
{
method: "GET",
query: options2
}
);
return response.data;
} catch (error) {
handleError(error);
return { usage: {} };
}
}
}
},
// Audit logs
audit: {
async list(_options) {
try {
const response = await fetch("/feature-flags/admin/audit", {
method: "GET"
});
return response.data;
} catch (error) {
handleError(error);
return { entries: [] };
}
},
async get(id) {
try {
const response = await fetch(
`/feature-flags/admin/audit/${id}`,
{
method: "GET"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
}
},
// Environments
environments: {
async list() {
try {
const response = await fetch(
"/feature-flags/admin/environments",
{
method: "GET"
}
);
return response.data;
} catch (error) {
handleError(error);
return { environments: [] };
}
},
async create(env) {
try {
const response = await fetch(
"/feature-flags/admin/environments",
{
method: "POST",
body: env
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async update(id, updates) {
try {
const response = await fetch(
`/feature-flags/admin/environments/${id}`,
{
method: "PATCH",
body: updates
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
},
async delete(id) {
try {
const response = await fetch(
`/feature-flags/admin/environments/${id}`,
{
method: "DELETE"
}
);
return response.data;
} catch (error) {
handleError(error);
throw error;
}
}
},
// Data exports
exports: {
async create(options2) {
try {
const response = await fetch("/feature-flags/admin/export", {
method: "POST",
body: options2
});
return response.data;
} catch (error) {
handleError(error);
throw error;
}
}
}
},
dispose() {
if (smartPoller) {
smartPoller.stop();
smartPoller = null;
}
if (sessionUnsubscribe) {
sessionUnsubscribe();
sessionUnsubscribe = null;
}
cache.clear();
subscribers.clear();
overrideManager.dispose();
}
}
};
if (options.polling?.enabled && options.polling.interval && typeof globalThis !== "undefined" && typeof globalThis.setTimeout === "function") {
if (smartPoller) {
smartPoller.stop();
}
smartPoller = new SmartPoller(
options.polling.interval,
async () => {
await actions.featureFlags.refresh();
},
(error) => {
handleError(
new Error(
`Polling