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
JavaScript
;
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;