@flagvault/sdk
Version:
Lightweight JavaScript SDK for FlagVault with intelligent caching, graceful error handling, and built-in React hooks for seamless feature flag integration.
798 lines • 30.9 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.FlagVaultAPIError = exports.FlagVaultNetworkError = exports.FlagVaultAuthenticationError = exports.FlagVaultError = void 0;
exports.useFeatureFlag = useFeatureFlag;
exports.useFeatureFlagCached = useFeatureFlagCached;
const crypto = __importStar(require("crypto"));
/**
* Base exception for FlagVault SDK errors.
*/
class FlagVaultError extends Error {
constructor(message) {
super(message);
this.name = "FlagVaultError";
}
}
exports.FlagVaultError = FlagVaultError;
/**
* Raised when authentication fails.
*/
class FlagVaultAuthenticationError extends FlagVaultError {
constructor(message) {
super(message);
this.name = "FlagVaultAuthenticationError";
}
}
exports.FlagVaultAuthenticationError = FlagVaultAuthenticationError;
/**
* Raised when network requests fail.
*/
class FlagVaultNetworkError extends FlagVaultError {
constructor(message) {
super(message);
this.name = "FlagVaultNetworkError";
}
}
exports.FlagVaultNetworkError = FlagVaultNetworkError;
/**
* Raised when the API returns an error response.
*/
class FlagVaultAPIError extends FlagVaultError {
constructor(message) {
super(message);
this.name = "FlagVaultAPIError";
}
}
exports.FlagVaultAPIError = FlagVaultAPIError;
/**
* FlagVault SDK for feature flag management.
*
* This SDK allows you to easily integrate feature flags into your JavaScript/TypeScript applications.
* Feature flags (also known as feature toggles) allow you to enable or disable features in your
* application without deploying new code.
*
* ## Installation
*
* ```bash
* npm install @flagvault/sdk
* # or
* yarn add @flagvault/sdk
* ```
*
* ## Basic Usage
*
* ```typescript
* import FlagVaultSDK from '@flagvault/sdk';
*
* const sdk = new FlagVaultSDK({
* apiKey: 'live_your-api-key-here' // Use 'test_' prefix for test environment
* });
*
* // Check if a feature flag is enabled
* const isEnabled = await sdk.isEnabled('my-feature-flag');
* if (isEnabled) {
* // Feature is enabled, run feature code
* } else {
* // Feature is disabled, run fallback code
* }
* ```
*
* ## Graceful Error Handling
*
* The SDK automatically handles errors gracefully by returning default values:
*
* ```typescript
* // No try/catch needed - errors are handled gracefully
* const isEnabled = await sdk.isEnabled('my-feature-flag', false);
*
* // On network error, you'll see:
* // FlagVault: Failed to connect to API for flag 'my-feature-flag', using default: false
*
* // On authentication error:
* // FlagVault: Invalid API credentials for flag 'my-feature-flag', using default: false
*
* // On missing flag:
* // FlagVault: Flag 'my-feature-flag' not found, using default: false
* ```
*
* ## Advanced Error Handling
*
* For custom error handling, you can still catch exceptions for parameter validation:
*
* ```typescript
* try {
* const isEnabled = await sdk.isEnabled('my-feature-flag', false);
* // ...
* } catch (error) {
* // Only throws for invalid parameters (empty flagKey)
* console.error('Parameter validation error:', error.message);
* }
* ```
*
* @group Core
*/
class FlagVaultSDK {
/**
* Creates a new instance of the FlagVault SDK.
*
* @param config - Configuration options for the SDK
* @throws Error if apiKey is not provided
*/
constructor(config) {
var _a, _b, _c, _d, _e;
this.refreshInProgress = false;
const { apiKey, baseUrl = "https://api.flagvault.com", timeout = 10000, cache = {}, } = config;
if (!apiKey) {
throw new Error("API Key is required to initialize the SDK.");
}
this.apiKey = apiKey;
this.baseUrl = baseUrl;
this.timeout = timeout;
// Initialize cache configuration with defaults
this.cacheConfig = {
enabled: (_a = cache.enabled) !== null && _a !== void 0 ? _a : true,
ttl: (_b = cache.ttl) !== null && _b !== void 0 ? _b : 300,
maxSize: (_c = cache.maxSize) !== null && _c !== void 0 ? _c : 1000,
refreshInterval: (_d = cache.refreshInterval) !== null && _d !== void 0 ? _d : 60,
fallbackBehavior: (_e = cache.fallbackBehavior) !== null && _e !== void 0 ? _e : "default",
};
// Initialize cache
this.cache = new Map();
// Start background refresh if enabled
if (this.cacheConfig.enabled && this.cacheConfig.refreshInterval > 0) {
this.startBackgroundRefresh();
}
}
/**
* Stops background refresh and cleans up resources.
* Call this when you're done with the SDK instance.
*/
destroy() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = undefined;
}
this.cache.clear();
}
/**
* Checks if a feature flag is enabled.
*
* @param flagKey - The key for the feature flag
* @param defaultValue - Default value to return on error (defaults to false)
* @param context - Optional context ID for percentage rollouts (e.g., userId, sessionId)
* @returns A promise that resolves to a boolean indicating if the feature is enabled
* @throws Error if flagKey is not provided
*/
isEnabled(flagKey_1) {
return __awaiter(this, arguments, void 0, function* (flagKey, defaultValue = false, context) {
if (!flagKey) {
throw new Error("flagKey is required to check if a feature is enabled.");
}
// Check bulk cache first if available
if (this.cacheConfig.enabled && this.bulkFlagsCache) {
const now = Date.now();
if (now < this.bulkFlagsCache.expiresAt) {
const flag = this.bulkFlagsCache.flags.get(flagKey);
if (flag) {
return this.evaluateFlag(flag, context);
}
}
}
// Check individual cache if enabled (include context in cache key)
const cacheKey = context ? `${flagKey}:${context}` : flagKey;
if (this.cacheConfig.enabled) {
const cachedValue = this.getCachedValue(cacheKey);
if (cachedValue !== null) {
return cachedValue;
}
}
// Cache miss - fetch from API
try {
const { value, shouldCache } = yield this.fetchFlagFromApiWithCacheInfo(flagKey, defaultValue, context);
// Store in cache if enabled and the response was successful
if (this.cacheConfig.enabled && shouldCache) {
this.setCachedValue(cacheKey, value);
}
return value;
}
catch (error) {
return this.handleCacheMiss(flagKey, defaultValue, error);
}
});
}
/**
* Fetches a flag value from the API with cache information.
* @private
*/
fetchFlagFromApiWithCacheInfo(flagKey, defaultValue, context) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
let url = `${this.baseUrl}/api/feature-flag/${flagKey}/enabled`;
if (context) {
url += `?context=${encodeURIComponent(context)}`;
}
// Create AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = yield fetch(url, {
method: "GET",
headers: {
"X-API-Key": this.apiKey,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
// Handle authentication errors - don't cache
if (response.status === 401) {
console.warn(`FlagVault: Invalid API credentials for flag '${flagKey}', using default: ${defaultValue}`);
return { value: defaultValue, shouldCache: false };
}
else if (response.status === 403) {
console.warn(`FlagVault: Access forbidden for flag '${flagKey}', using default: ${defaultValue}`);
return { value: defaultValue, shouldCache: false };
}
// Handle flag not found - don't cache
if (response.status === 404) {
console.warn(`FlagVault: Flag '${flagKey}' not found, using default: ${defaultValue}`);
return { value: defaultValue, shouldCache: false };
}
// Handle other HTTP errors - don't cache
if (!response.ok) {
let errorMessage;
try {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
catch (_b) {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
console.warn(`FlagVault: API error for flag '${flagKey}' (${errorMessage}), using default: ${defaultValue}`);
return { value: defaultValue, shouldCache: false };
}
// Parse JSON response
let data;
try {
data = yield response.json();
}
catch (_c) {
console.warn(`FlagVault: Invalid JSON response for flag '${flagKey}', using default: ${defaultValue}`);
return { value: defaultValue, shouldCache: false };
}
// Extract enabled value and cache successful responses
const enabled = (_a = data === null || data === void 0 ? void 0 : data.enabled) !== null && _a !== void 0 ? _a : false;
return { value: enabled, shouldCache: true };
}
catch (error) {
clearTimeout(timeoutId);
// Handle network and timeout errors gracefully - don't cache
if (error instanceof DOMException && error.name === "AbortError") {
console.warn(`FlagVault: Request timed out for flag '${flagKey}' after ${this.timeout}ms, using default: ${defaultValue}`);
return { value: defaultValue, shouldCache: false };
}
if (error instanceof TypeError) {
console.warn(`FlagVault: Failed to connect to API for flag '${flagKey}', using default: ${defaultValue}`);
return { value: defaultValue, shouldCache: false };
}
console.warn(`FlagVault: Network error for flag '${flagKey}' (${error}), using default: ${defaultValue}`);
return { value: defaultValue, shouldCache: false };
}
});
}
/**
* Fetches a flag value from the API.
* @private
*/
fetchFlagFromApi(flagKey, defaultValue, context) {
return __awaiter(this, void 0, void 0, function* () {
const { value } = yield this.fetchFlagFromApiWithCacheInfo(flagKey, defaultValue, context);
return value;
});
}
/**
* Gets a cached flag value if it exists and is not expired.
* @private
*/
getCachedValue(flagKey) {
const entry = this.cache.get(flagKey);
if (!entry) {
return null;
}
const now = Date.now();
if (now > entry.expiresAt) {
this.cache.delete(flagKey);
return null;
}
// Update last accessed time for LRU
entry.lastAccessed = now;
return entry.value;
}
/**
* Sets a flag value in the cache.
* @private
*/
setCachedValue(flagKey, value) {
// Check if cache is full and evict oldest entry
if (this.cache.size >= this.cacheConfig.maxSize) {
this.evictOldestEntry();
}
const now = Date.now();
const entry = {
value,
cachedAt: now,
expiresAt: now + this.cacheConfig.ttl * 1000,
lastAccessed: now,
};
this.cache.set(flagKey, entry);
}
/**
* Evicts the least recently used entry from the cache.
* @private
*/
evictOldestEntry() {
let oldestKey = "";
let oldestTime = Infinity;
for (const [key, entry] of this.cache.entries()) {
if (entry.lastAccessed < oldestTime) {
oldestTime = entry.lastAccessed;
oldestKey = key;
}
}
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
/**
* Handles cache miss scenarios based on configured fallback behavior.
* @private
*/
handleCacheMiss(flagKey, defaultValue, error) {
switch (this.cacheConfig.fallbackBehavior) {
case "default":
console.warn(`FlagVault: Cache miss for '${flagKey}', using default: ${defaultValue}`);
return defaultValue;
case "throw":
throw error;
case "api":
// This would retry the API call, but for now we'll return default
console.warn(`FlagVault: Cache miss for '${flagKey}', using default: ${defaultValue}`);
return defaultValue;
default:
return defaultValue;
}
}
/**
* Starts the background refresh timer.
* @private
*/
startBackgroundRefresh() {
this.refreshTimer = setInterval(() => {
if (!this.refreshInProgress) {
this.refreshExpiredFlags();
}
}, this.cacheConfig.refreshInterval * 1000);
}
/**
* Refreshes flags that are about to expire.
* @private
*/
refreshExpiredFlags() {
return __awaiter(this, void 0, void 0, function* () {
this.refreshInProgress = true;
try {
const now = Date.now();
const flagsToRefresh = [];
// Find flags that will expire in the next 30 seconds
// Only refresh flags without context (basic flag keys)
for (const [flagKey, entry] of this.cache.entries()) {
const timeUntilExpiry = entry.expiresAt - now;
if (timeUntilExpiry <= 30000 && !flagKey.includes(":")) {
// 30 seconds, no context
flagsToRefresh.push(flagKey);
}
}
if (flagsToRefresh.length > 0) {
// Refresh flags in the background
yield Promise.allSettled(flagsToRefresh.map((flagKey) => __awaiter(this, void 0, void 0, function* () {
try {
const { value, shouldCache } = yield this.fetchFlagFromApiWithCacheInfo(flagKey, false);
if (shouldCache) {
this.setCachedValue(flagKey, value);
}
}
catch (error) {
// Background refresh failed, but don't remove from cache
console.warn(`FlagVault: Background refresh failed for '${flagKey}':`, error);
}
})));
}
}
catch (error) {
console.warn("FlagVault: Background refresh failed:", error);
}
finally {
this.refreshInProgress = false;
}
});
}
/**
* Gets cache statistics for monitoring and debugging.
*
* @returns Object containing cache statistics
*/
getCacheStats() {
let hitCount = 0;
let expiredCount = 0;
const now = Date.now();
for (const entry of this.cache.values()) {
if (entry.lastAccessed > entry.cachedAt) {
hitCount++;
}
if (now > entry.expiresAt) {
expiredCount++;
}
}
return {
size: this.cache.size,
hitRate: this.cache.size > 0 ? hitCount / this.cache.size : 0,
expiredEntries: expiredCount,
memoryUsage: this.estimateMemoryUsage(),
};
}
/**
* Gets debug information for a specific flag.
*
* @param flagKey - The flag key to debug
* @returns Debug information about the flag
*/
debugFlag(flagKey) {
const entry = this.cache.get(flagKey);
const now = Date.now();
return {
flagKey,
cached: !!entry,
value: entry === null || entry === void 0 ? void 0 : entry.value,
cachedAt: entry === null || entry === void 0 ? void 0 : entry.cachedAt,
expiresAt: entry === null || entry === void 0 ? void 0 : entry.expiresAt,
timeUntilExpiry: entry ? entry.expiresAt - now : undefined,
lastAccessed: entry === null || entry === void 0 ? void 0 : entry.lastAccessed,
};
}
/**
* Clears the entire cache.
*/
clearCache() {
this.cache.clear();
}
/**
* Estimates memory usage of the cache.
* @private
*/
estimateMemoryUsage() {
// Rough estimation: each entry has a key (string) + CacheEntry object
// String: ~2 bytes per character, CacheEntry: ~32 bytes for numbers
let total = 0;
for (const key of this.cache.keys()) {
total += key.length * 2 + 32; // Rough estimate
}
return total;
}
/**
* Fetches all feature flags for the organization.
*
* @returns A promise that resolves to a map of flag keys to flag metadata
* @throws Error on network or API errors
*/
getAllFlags() {
return __awaiter(this, void 0, void 0, function* () {
// Check bulk cache first
if (this.cacheConfig.enabled && this.bulkFlagsCache) {
const now = Date.now();
if (now < this.bulkFlagsCache.expiresAt) {
return new Map(this.bulkFlagsCache.flags);
}
}
const url = `${this.baseUrl}/api/feature-flag`;
// Create AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = yield fetch(url, {
method: "GET",
headers: {
"X-API-Key": this.apiKey,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new FlagVaultAPIError(`Failed to fetch flags: ${response.status} ${response.statusText}`);
}
const data = yield response.json();
const flags = new Map();
if (data.flags && Array.isArray(data.flags)) {
for (const flag of data.flags) {
flags.set(flag.key, flag);
}
}
// Cache the bulk response
if (this.cacheConfig.enabled) {
const now = Date.now();
this.bulkFlagsCache = {
flags: new Map(flags),
cachedAt: now,
expiresAt: now + this.cacheConfig.ttl * 1000,
};
}
return flags;
}
catch (error) {
clearTimeout(timeoutId);
if (error instanceof DOMException && error.name === "AbortError") {
throw new FlagVaultNetworkError(`Request timed out after ${this.timeout}ms`);
}
if (error instanceof TypeError) {
throw new FlagVaultNetworkError("Failed to connect to API");
}
throw error;
}
});
}
/**
* Evaluates a feature flag for a specific context using local rollout logic.
* @private
*/
evaluateFlag(flag, context) {
// If flag is disabled, always return false
if (!flag.isEnabled) {
return false;
}
// If no rollout percentage set, return the flag's enabled state
if (flag.rolloutPercentage == null || flag.rolloutSeed == null) {
return flag.isEnabled;
}
// Use provided context or generate a random one
const rolloutContext = context || crypto.randomBytes(16).toString("hex");
// Calculate consistent hash for this context + flag combination
const hash = crypto
.createHash("sha256")
.update(`${rolloutContext}-${flag.key}-${flag.rolloutSeed}`)
.digest();
// Convert first 2 bytes to a number between 0-9999 (for 0.01% precision)
const bucket = (hash[0] * 256 + hash[1]) % 10000;
// Check if this context is in the rollout percentage
const threshold = flag.rolloutPercentage * 100; // Convert percentage to 0-10000 scale
return bucket < threshold;
}
/**
* Preloads all feature flags into cache.
* Useful for applications that need to evaluate many flags quickly.
*
* @returns A promise that resolves when flags are loaded
*/
preloadFlags() {
return __awaiter(this, void 0, void 0, function* () {
yield this.getAllFlags();
});
}
}
exports.default = FlagVaultSDK;
/**
* React hook for checking feature flag status.
*
* @param sdk - FlagVault SDK instance
* @param flagKey - The feature flag key to check
* @param defaultValue - Default value to use if flag cannot be loaded
* @param context - Optional context ID for percentage rollouts (e.g., userId, sessionId)
* @returns Object containing isEnabled, isLoading, and error states
*
* @example
* ```tsx
* import FlagVaultSDK, { useFeatureFlag } from '@flagvault/sdk';
*
* const sdk = new FlagVaultSDK({ apiKey: 'live_your-api-key' });
*
* function MyComponent() {
* const { isEnabled, isLoading, error } = useFeatureFlag(sdk, 'new-feature', false, 'user-123');
*
* if (isLoading) return <div>Loading...</div>;
* if (error) return <div>Error: {error.message}</div>;
*
* return isEnabled ? <NewFeature /> : <OldFeature />;
* }
* ```
*
* @group React Hooks
*/
function useFeatureFlag(sdk, flagKey, defaultValue = false, context) {
// Note: This requires React to be installed as a peer dependency
// We use a try/catch to gracefully handle cases where React is not available
try {
// Use any to avoid TypeScript errors with dynamic imports
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-require-imports
const React = require("react");
const { useState, useEffect } = React;
const [isEnabled, setIsEnabled] = useState(defaultValue);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
function checkFlag() {
return __awaiter(this, void 0, void 0, function* () {
try {
setIsLoading(true);
const enabled = yield sdk.isEnabled(flagKey, defaultValue, context);
if (isMounted) {
setIsEnabled(enabled);
setError(null);
}
}
catch (err) {
if (isMounted) {
const errorObj = err instanceof Error ? err : new Error(String(err));
setError(errorObj);
setIsEnabled(defaultValue);
}
}
finally {
if (isMounted) {
setIsLoading(false);
}
}
});
}
checkFlag();
return () => {
isMounted = false;
};
}, [sdk, flagKey, defaultValue, context]);
return { isEnabled, isLoading, error };
}
catch (_a) {
throw new Error("useFeatureFlag requires React to be installed as a peer dependency. " +
"Please install React: npm install react");
}
}
/**
* React hook for checking feature flag status with caching.
* Reduces API calls by caching flag values for a specified TTL.
*
* @param sdk - FlagVault SDK instance
* @param flagKey - The feature flag key to check
* @param defaultValue - Default value to use if flag cannot be loaded
* @param cacheTTL - Cache time-to-live in milliseconds (default: 5 minutes)
* @param context - Optional context ID for percentage rollouts (e.g., userId, sessionId)
* @returns Object containing isEnabled, isLoading, and error states
*
* @example
* ```tsx
* import FlagVaultSDK, { useFeatureFlagCached } from '@flagvault/sdk';
*
* const sdk = new FlagVaultSDK({ apiKey: 'live_your-api-key' });
*
* function MyComponent() {
* const { isEnabled, isLoading } = useFeatureFlagCached(
* sdk,
* 'new-feature',
* false,
* 300000, // 5 minutes cache
* 'user-123'
* );
*
* return isEnabled ? <NewFeature /> : <OldFeature />;
* }
* ```
*
* @group React Hooks
*/
function useFeatureFlagCached(sdk, flagKey, defaultValue = false, cacheTTL = 5 * 60 * 1000, // 5 minutes
context) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-require-imports
const React = require("react");
const { useState, useEffect } = React;
const [isEnabled, setIsEnabled] = useState(defaultValue);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
function checkFlag() {
return __awaiter(this, void 0, void 0, function* () {
try {
setIsLoading(true);
// Check cache first (include context in cache key)
const cacheKey = context
? `flagvault_${flagKey}:${context}`
: `flagvault_${flagKey}`;
const cachedData = getCachedFlag(cacheKey, cacheTTL);
if (cachedData !== null) {
if (isMounted) {
setIsEnabled(cachedData);
setError(null);
setIsLoading(false);
}
return;
}
// Fetch from API
const enabled = yield sdk.isEnabled(flagKey, defaultValue, context);
// Update cache
setCachedFlag(cacheKey, enabled);
if (isMounted) {
setIsEnabled(enabled);
setError(null);
}
}
catch (err) {
if (isMounted) {
const errorObj = err instanceof Error ? err : new Error(String(err));
setError(errorObj);
setIsEnabled(defaultValue);
}
}
finally {
if (isMounted) {
setIsLoading(false);
}
}
});
}
checkFlag();
return () => {
isMounted = false;
};
}, [sdk, flagKey, defaultValue, cacheTTL, context]);
return { isEnabled, isLoading, error };
}
catch (_a) {
throw new Error("useFeatureFlagCached requires React to be installed as a peer dependency. " +
"Please install React: npm install react");
}
}
// Simple in-memory cache for React hooks
const flagCache = new Map();
function getCachedFlag(key, ttl) {
const cached = flagCache.get(key);
if (!cached)
return null;
const now = Date.now();
if (now - cached.timestamp > ttl) {
flagCache.delete(key);
return null;
}
return cached.value;
}
function setCachedFlag(key, value) {
flagCache.set(key, { value, timestamp: Date.now() });
}
//# sourceMappingURL=index.js.map