@varia-bly/variably-sdk
Version:
Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, LLM experiments with React hooks, and real-time dynamic configurations
367 lines • 14.1 kB
JavaScript
/**
* GraphQL WebSocket subscription client for real-time Feature Gate updates
* Uses graphql-ws protocol for GraphQL subscriptions
*/
import { createClient } from 'graphql-ws';
// Use native WebSocket in browser, ws package in Node.js
const WebSocketImpl = typeof window !== 'undefined' && window.WebSocket
? window.WebSocket
: require('ws');
export class GraphQLSubscriptionClient {
constructor(config, logger) {
this.client = null;
this.connectionState = 'disconnected';
// Callbacks
this.gateUpdateCallbacks = new Map();
this.connectionStateCallbacks = new Set();
// Subscription management
this.activeSubscriptions = new Map();
this.reconnectAttempts = 0;
this.reconnectTimer = null;
this.config = {
url: config.url,
apiKey: config.apiKey,
projectId: config.projectId,
autoReconnect: config.autoReconnect !== false,
reconnectInterval: config.reconnectInterval || 5000,
maxReconnectAttempts: config.maxReconnectAttempts || 10
};
this.logger = logger;
}
/**
* Connect to GraphQL WebSocket server
*/
async connect() {
if (this.connectionState === 'connected' || this.connectionState === 'connecting') {
return;
}
return new Promise((resolve, reject) => {
try {
this.setConnectionState('connecting');
// Convert HTTP URL to WebSocket URL and append /graphql path
const baseWsUrl = this.config.url
.replace(/^http:/, 'ws:')
.replace(/^https:/, 'wss:');
// Append /graphql if not already present
const wsUrl = baseWsUrl.endsWith('/graphql') ? baseWsUrl : `${baseWsUrl}/graphql`;
this.logger.debug('Connecting to GraphQL WebSocket', { url: wsUrl });
const clientOptions = {
url: wsUrl,
webSocketImpl: WebSocketImpl,
connectionParams: {
apiKey: this.config.apiKey
},
retryAttempts: 0, // We handle reconnection ourselves
lazy: false,
keepAlive: 30000, // 30 seconds
on: {
opened: (socket) => {
this.setConnectionState('connected');
this.reconnectAttempts = 0;
this.logger.info('GraphQL WebSocket connected successfully');
// Resubscribe to active subscriptions
this.resubscribeAll();
resolve();
},
closed: (event) => {
this.setConnectionState('disconnected');
this.logger.warn('GraphQL WebSocket connection closed', {
code: event.code,
reason: event.reason
});
// Attempt reconnection if enabled
if (this.config.autoReconnect && this.reconnectAttempts < this.config.maxReconnectAttempts) {
this.scheduleReconnect();
}
},
error: (error) => {
this.setConnectionState('error');
this.logger.error('GraphQL WebSocket error', {
error: error instanceof Error ? error.message : String(error)
});
if (this.connectionState === 'connecting') {
reject(error);
}
},
ping: (received) => {
this.logger.debug('Received ping from server');
},
pong: (received) => {
this.logger.debug('Received pong from server');
}
}
};
this.client = createClient(clientOptions);
}
catch (error) {
this.setConnectionState('error');
const wsError = error instanceof Error ? error : new Error('Unknown WebSocket error');
this.logger.error('Failed to create GraphQL WebSocket connection', { error: wsError.message });
reject(wsError);
}
});
}
/**
* Disconnect from GraphQL WebSocket server
*/
disconnect() {
this.config.autoReconnect = false;
this.clearReconnectTimer();
// Unsubscribe from all active subscriptions
this.activeSubscriptions.forEach((unsubscribe) => {
unsubscribe();
});
this.activeSubscriptions.clear();
if (this.client) {
this.client.dispose();
this.client = null;
}
this.setConnectionState('disconnected');
this.logger.info('GraphQL WebSocket disconnected');
}
/**
* Subscribe to Feature Gate updates for a specific project
*/
subscribeToFeatureGates(projectId, gateKey) {
const subscriptionId = gateKey ? `${projectId}:${gateKey}` : projectId;
// If already subscribed, return existing unsubscribe function
if (this.activeSubscriptions.has(subscriptionId)) {
this.logger.debug('Already subscribed to Feature Gate updates', { projectId, gateKey });
return this.activeSubscriptions.get(subscriptionId);
}
if (!this.client) {
this.logger.warn('Cannot subscribe: Client not connected');
return () => { };
}
this.logger.info('Subscribing to Feature Gate updates', { projectId, gateKey });
const subscription = {
query: `
subscription FeatureGateUpdated($projectId: ID!, $gateKey: String) {
featureGateUpdated(projectId: $projectId, gateKey: $gateKey) {
id
gateKey
projectId
isEnabled
description
trafficAllocation
updatedAt
}
}
`,
variables: {
projectId,
gateKey: gateKey || null
}
};
const unsubscribe = this.client.subscribe(subscription, {
next: (data) => {
this.logger.debug('Received Feature Gate update', { data });
if (data.data && data.data.featureGateUpdated) {
const gateUpdate = data.data.featureGateUpdated;
this.handleGateUpdate(gateUpdate);
}
},
error: (errors) => {
// graphql-ws returns an array of GraphQL errors
let errorMessage = 'Unknown error';
if (Array.isArray(errors) && errors.length > 0) {
errorMessage = errors.map((e) => e.message || JSON.stringify(e)).join(', ');
}
else if (errors instanceof Error) {
errorMessage = errors.message;
}
else if (typeof errors === 'string') {
errorMessage = errors;
}
else {
try {
errorMessage = JSON.stringify(errors);
}
catch {
errorMessage = String(errors);
}
}
this.logger.error('Feature Gate subscription error', {
error: errorMessage,
projectId,
gateKey
});
},
complete: () => {
this.logger.debug('Feature Gate subscription completed', { projectId, gateKey });
this.activeSubscriptions.delete(subscriptionId);
}
});
this.activeSubscriptions.set(subscriptionId, unsubscribe);
// Return unsubscribe function
return () => {
this.logger.debug('Unsubscribing from Feature Gate updates', { projectId, gateKey });
unsubscribe();
this.activeSubscriptions.delete(subscriptionId);
};
}
/**
* Add callback for Feature Gate updates
*/
onFeatureGateUpdate(gateKey, callback) {
if (!this.gateUpdateCallbacks.has(gateKey)) {
this.gateUpdateCallbacks.set(gateKey, new Set());
}
this.gateUpdateCallbacks.get(gateKey).add(callback);
// Return unsubscribe function
return () => {
const callbacks = this.gateUpdateCallbacks.get(gateKey);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.gateUpdateCallbacks.delete(gateKey);
}
}
};
}
/**
* Add callback for all Feature Gate updates (wildcard)
*/
onAnyFeatureGateUpdate(callback) {
return this.onFeatureGateUpdate('*', callback);
}
/**
* Add callback for connection state changes
*/
onConnectionState(callback) {
this.connectionStateCallbacks.add(callback);
// Return unsubscribe function
return () => {
this.connectionStateCallbacks.delete(callback);
};
}
/**
* Get current connection state
*/
getConnectionState() {
return this.connectionState;
}
/**
* Check if client is connected
*/
isConnected() {
return this.connectionState === 'connected' && this.client !== null;
}
/**
* Handle Feature Gate update from subscription
*/
handleGateUpdate(gateUpdate) {
this.logger.info('Processing Feature Gate update', {
gateKey: gateUpdate.gateKey,
enabled: gateUpdate.isEnabled,
trafficAllocation: gateUpdate.trafficAllocation,
projectId: gateUpdate.projectId
});
// Notify specific gate callbacks
const callbacks = this.gateUpdateCallbacks.get(gateUpdate.gateKey);
if (callbacks) {
callbacks.forEach(callback => {
try {
callback(gateUpdate);
}
catch (error) {
this.logger.error('Feature Gate update callback error', {
gateKey: gateUpdate.gateKey,
error: error instanceof Error ? error.message : String(error)
});
}
});
}
// Notify wildcard callbacks
const wildcardCallbacks = this.gateUpdateCallbacks.get('*');
if (wildcardCallbacks) {
wildcardCallbacks.forEach(callback => {
try {
callback(gateUpdate);
}
catch (error) {
this.logger.error('Wildcard Feature Gate update callback error', {
gateKey: gateUpdate.gateKey,
error: error instanceof Error ? error.message : String(error)
});
}
});
}
}
/**
* Resubscribe to all active subscriptions after reconnection
*/
resubscribeAll() {
const subscriptionIds = Array.from(this.activeSubscriptions.keys());
if (subscriptionIds.length === 0) {
return;
}
this.logger.info('Resubscribing to all active subscriptions', {
count: subscriptionIds.length
});
// Clear existing subscriptions
this.activeSubscriptions.forEach((unsubscribe) => {
unsubscribe();
});
this.activeSubscriptions.clear();
// Resubscribe to each subscription
subscriptionIds.forEach((subscriptionId) => {
const [projectId, gateKey] = subscriptionId.split(':');
this.subscribeToFeatureGates(projectId, gateKey);
});
}
/**
* Schedule reconnection attempt
*/
scheduleReconnect() {
if (this.reconnectTimer) {
return;
}
this.reconnectAttempts++;
const delay = Math.min(this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), 30000);
this.logger.info(`Scheduling reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`, {
delay
});
this.reconnectTimer = setTimeout(async () => {
this.clearReconnectTimer();
try {
await this.connect();
}
catch (error) {
this.logger.error('Reconnection attempt failed', {
attempt: this.reconnectAttempts,
error: error instanceof Error ? error.message : String(error)
});
}
}, delay);
}
/**
* Clear reconnection timer
*/
clearReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
/**
* Update connection state and notify callbacks
*/
setConnectionState(state) {
if (this.connectionState !== state) {
this.connectionState = state;
this.logger.debug('Connection state changed', { state });
this.connectionStateCallbacks.forEach(callback => {
try {
callback(state);
}
catch (error) {
this.logger.error('Connection state callback error', {
error: error instanceof Error ? error.message : String(error)
});
}
});
}
}
}
//# sourceMappingURL=graphql-subscription-client.js.map