UNPKG

@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
"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