UNPKG

flagmint-vuejs-feature-flags

Version:

A Vue.js SDK for managing feature flags in Flagmint applications, supporting both Vue 2 and Vue 3.

288 lines (283 loc) 13.3 kB
'use strict'; var vueDemi = require('vue-demi'); var flagmintJsSdk = require('flagmint-js-sdk'); const FLAGMINT_INIT = Symbol.for('FLAGMINT_INIT'); const FLAGMINT_READY = Symbol.for('FLAGMINT_READY'); const FLAGMINT_CLIENT = Symbol.for('FLAGMINT_CLIENT'); const FLAGMINT_STATE = Symbol.for('FLAGMINT_STATE'); const createFeatureFlagPlugin = (options) => ({ install(appOrVue) { // Scoped to this install call — no shared module-level state const flagClientRef = vueDemi.ref(null); const readyRef = vueDemi.ref(false); const flagStateRef = vueDemi.ref(0); //single in-flight promise shared across all concurrent init() callers let initPromise = null; // Provide early — inject() in components will see these immediately, // flagClientRef.value is null until init() resolves which is expected if (vueDemi.isVue3) { appOrVue.provide(FLAGMINT_READY, vueDemi.readonly(readyRef)); appOrVue.provide(FLAGMINT_CLIENT, flagClientRef); appOrVue.provide(FLAGMINT_STATE, vueDemi.readonly(flagStateRef)); } const { deferInitialization, syncCrossIframes, syncNamespace, ...pureOptions } = options; const setupCrossIframeSync = (resolveInit, pureOptions) => { const ns = options.syncNamespace || 'default_global'; const channel = new BroadcastChannel(`flagmint_sync_${ns}`); const instanceId = Math.random().toString(36).substring(2, 9); let isLeader = false; let heartbeatInterval = null; let checkTimeout = null; let underlyingClient = null; let mockClient = null; let cachedFlags = {}; const subscribers = new Set(); const promoteToLeader = async () => { // SECURITY GUARD: If the window went to sleep before this timer resolved, abort! if (document.hidden) { keepAsFollower(); return; } if (isLeader) return; isLeader = true; clearTimeout(checkTimeout); console.log(`[Flagmint] Instance ${instanceId} elected LEADER. Spawning single WebSocket.`); const clientInstance = new flagmintJsSdk.FlagClient(pureOptions); underlyingClient = clientInstance; await clientInstance.ready(); // ASYNC GUARD: Check if a tie-breaker or background event occurred while waiting for the network if (!isLeader || !underlyingClient || document.hidden) { if (clientInstance) clientInstance.destroy(); if (underlyingClient === clientInstance) underlyingClient = null; return; } cachedFlags = underlyingClient.getFlags() || {}; underlyingClient.subscribe((updatedFlags) => { if (!isLeader || !underlyingClient || document.hidden) return; cachedFlags = updatedFlags; flagStateRef.value++; if (vueDemi.isVue3) vueDemi.triggerRef(flagClientRef); subscribers.forEach(cb => cb(updatedFlags)); channel.postMessage({ type: 'FLAG_UPDATE', flags: updatedFlags }); }); if (!flagClientRef.value || flagClientRef.value === mockClient) { flagClientRef.value = underlyingClient; readyRef.value = true; resolveInit(underlyingClient); } heartbeatInterval = setInterval(() => { if (!document.hidden) { channel.postMessage({ type: 'HEARTBEAT', leaderId: instanceId }); } }, 1000); }; const keepAsFollower = () => { isLeader = false; if (!mockClient) { mockClient = { subscribe: (callback) => { subscribers.add(callback); callback(cachedFlags); return () => subscribers.delete(callback); }, getFlags: () => cachedFlags, getFlag: (key, fallback) => { var _a; return (_a = cachedFlags[key]) !== null && _a !== void 0 ? _a : fallback; }, updateContext: async (newContext) => { channel.postMessage({ type: 'UPDATE_CONTEXT', context: newContext }); } }; } if (!flagClientRef.value || flagClientRef.value === underlyingClient) { flagClientRef.value = mockClient; readyRef.value = true; resolveInit(mockClient); } clearTimeout(checkTimeout); // Followers only poll for status if they are visible in the foreground if (!document.hidden) { checkTimeout = setTimeout(() => { promoteToLeader(); }, 2500); } }; const stepDownGracefully = () => { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (underlyingClient) { underlyingClient.destroy(); // Tear down the active background connection row instantly underlyingClient = null; } keepAsFollower(); }; // --- VISIBILITY CHANGE EVENT ENGINE --- const handleVisibilityChange = () => { if (document.hidden) { if (isLeader) { channel.postMessage({ type: 'HEARTBEAT_DEAD' }); stepDownGracefully(); } else { keepAsFollower(); } } else { // Re-entering foreground: request active flag state immediately channel.postMessage({ type: 'REQ_INITIAL_STATE' }); clearTimeout(checkTimeout); checkTimeout = setTimeout(() => { if (!isLeader && (!flagClientRef.value || flagClientRef.value === mockClient)) { promoteToLeader(); } }, Math.floor(Math.random() * 150) + 50); } }; channel.onmessage = (event) => { const { type, flags, leaderId, context } = event.data; // Disregard background traffic sync checks entirely if this DOM tree is hidden if (document.hidden) return; if (type === 'HEARTBEAT') { if (!isLeader) { keepAsFollower(); return; } if (isLeader && leaderId !== instanceId && leaderId < instanceId) { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (underlyingClient) { underlyingClient.destroy(); underlyingClient = null; } keepAsFollower(); } } if (type === 'FLAG_UPDATE' && !isLeader) { cachedFlags = flags; flagStateRef.value++; if (vueDemi.isVue3) vueDemi.triggerRef(flagClientRef); subscribers.forEach(cb => cb(flags)); } if (type === 'REQ_INITIAL_STATE' && isLeader) { channel.postMessage({ type: 'FLAG_UPDATE', flags: cachedFlags }); } if (type === 'UPDATE_CONTEXT' && isLeader && underlyingClient) { underlyingClient.updateContext(context); } }; document.addEventListener('visibilitychange', handleVisibilityChange); window.addEventListener('beforeunload', () => { document.removeEventListener('visibilitychange', handleVisibilityChange); clearInterval(heartbeatInterval); clearTimeout(checkTimeout); if (underlyingClient) underlyingClient.destroy(); if (isLeader) channel.postMessage({ type: 'HEARTBEAT_DEAD' }); channel.close(); }); channel.addEventListener('message', (e) => { if (e.data.type === 'HEARTBEAT_DEAD' && !isLeader && !document.hidden) { promoteToLeader(); } }); // --- STARTUP STAGE LOGIC --- if (!document.hidden) { channel.postMessage({ type: 'REQ_INITIAL_STATE' }); checkTimeout = setTimeout(() => { if (!isLeader && !flagClientRef.value) promoteToLeader(); }, Math.floor(Math.random() * 140) + 40); } else { keepAsFollower(); } }; const init = async () => { // Already initialised — return existing client if (flagClientRef.value) return flagClientRef.value; // Init in flight — return the same promise instead of creating a second client if (initPromise) return initPromise; initPromise = new Promise((resolve) => { // If sync cross-iframes is enabled, divert initialization through the mesh-network coordinator if (options.syncCrossIframes) { setupCrossIframeSync(resolve, pureOptions); } else { // Standard path: Instantiate regular standalone sockets per execution container const client = new flagmintJsSdk.FlagClient(pureOptions); client.ready().then(() => { flagClientRef.value = client; readyRef.value = true; client.subscribe(() => { flagStateRef.value++; if (vueDemi.isVue3) vueDemi.triggerRef(flagClientRef); }); resolve(client); }); } }).then((resolvedClient) => { // Run native Vue property injection tasks if (vueDemi.isVue3) { appOrVue.config.globalProperties.$flagmint = flagClientRef; appOrVue.config.globalProperties.$flagmintReady = readyRef; } else if (vueDemi.isVue2) { const Vue = appOrVue.constructor; if (Vue.observable && !Vue.prototype.$flagmintState) { Vue.prototype.$flagmintState = Vue.observable({ flagClientRef, readyRef, flagStateRef, }); } const define = (key, descriptor) => { if (!Object.getOwnPropertyDescriptor(Vue.prototype, key)) { Object.defineProperty(Vue.prototype, key, descriptor); } }; define('$flagmint', { configurable: true, get() { var _a; return (_a = Vue.prototype.$flagmintState.flagClientRef.value) !== null && _a !== void 0 ? _a : null; }, }); define('$flagmintReady', { configurable: true, get() { var _a; return (_a = Vue.prototype.$flagmintState.readyRef.value) !== null && _a !== void 0 ? _a : null; }, }); define('$flagmintInit', { configurable: true, value: init, }); } return resolvedClient; }); return initPromise; }; if (vueDemi.isVue3) { appOrVue.provide(FLAGMINT_INIT, init); } else if (vueDemi.isVue2) { appOrVue.prototype.$flagmintInit = init; } if (!options.deferInitialization) { void init(); } }, }); exports.FLAGMINT_CLIENT = FLAGMINT_CLIENT; exports.FLAGMINT_INIT = FLAGMINT_INIT; exports.FLAGMINT_READY = FLAGMINT_READY; exports.FLAGMINT_STATE = FLAGMINT_STATE; exports.createFeatureFlagPlugin = createFeatureFlagPlugin;