UNPKG

lula2

Version:

A tool for managing compliance as code in your GitHub repositories.

390 lines (339 loc) 9.71 kB
// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023-Present The Lula Authors import { browser } from '$app/environment'; import { writable } from 'svelte/store'; import type { Control, Mapping } from './types'; export interface WSMessage { type: | 'state-update' | 'connected' | 'error' | 'metadata-update' | 'controls-update' | 'mappings-update' | 'control-details' | 'control-sets-list' | 'control-updated' | 'mapping-created' | 'mapping-updated' | 'mapping-deleted'; payload?: any; } export interface AppState { // Control set properties are spread at root level id?: string; name?: string; title?: string; version?: string; description?: string; fieldSchema?: any; field_schema?: any; control_id_field?: string; project?: { framework?: { baseline?: string; }; }; // State properties currentPath: string; controls: Control[]; mappings: Mapping[]; families: string[]; totalControls: number; totalMappings: number; isConnected: boolean; isSwitchingControlSet?: boolean; } // Create a writable store for the complete app state export const appState = writable<AppState>({ id: 'unknown', name: 'Unknown Control Set', currentPath: '', controls: [], mappings: [], families: [], totalControls: 0, totalMappings: 0, isConnected: false }); class WebSocketClient { private ws: WebSocket | null = null; private reconnectTimer: number | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; private reconnectDelay = 1000; // Start with 1 second connect() { if (!browser) return; // Don't create a new connection if one already exists if ( this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) ) { console.log('WebSocket already connected or connecting'); return; } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // In development, WebSocket is on port 3000, not the dev server port const host = browser && window.location.hostname === 'localhost' ? 'localhost:3000' : window.location.host; const wsUrl = `${protocol}//${host}/ws`; console.log('Connecting to WebSocket:', wsUrl); try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('WebSocket connected'); this.reconnectAttempts = 0; this.reconnectDelay = 1000; }; this.ws.onmessage = (event) => { try { const message: WSMessage = JSON.parse(event.data); this.handleMessage(message); } catch (error) { console.error('Failed to parse WebSocket message:', error); } }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; this.ws.onclose = () => { console.log('WebSocket disconnected'); appState.update((state) => ({ ...state, isConnected: false })); this.ws = null; this.scheduleReconnect(); }; } catch (error) { console.error('Failed to create WebSocket:', error); this.scheduleReconnect(); } } private handleMessage(message: WSMessage) { console.log('WebSocket message received:', message); switch (message.type) { case 'connected': this.handleConnected(); break; case 'state-update': this.handleStateUpdate(message.payload); break; case 'metadata-update': this.handleMetadataUpdate(message.payload); break; case 'controls-update': this.handleControlsUpdate(message.payload); break; case 'mappings-update': this.handleMappingsUpdate(message.payload); break; case 'control-details': this.handleControlDetails(message.payload); break; case 'control-sets-list': this.handleControlSetsList(message.payload); break; case 'control-updated': this.handleControlUpdated(message.payload); break; case 'mapping-created': case 'mapping-updated': case 'mapping-deleted': this.handleMappingOperation(message.type, message.payload); break; case 'error': this.handleError(message.payload); break; default: console.warn('Unknown WebSocket message type:', message.type); } } handleConnected() { console.log('WebSocket connection confirmed'); appState.update((state) => ({ ...state, isConnected: true })); } handleStateUpdate(payload: any) { console.log('State update received'); // Update the entire app state with the payload if (payload) { appState.set({ ...payload, isConnected: true, isSwitchingControlSet: false // Clear switching flag after state update }); } } handleMetadataUpdate(payload: any) { console.log('Metadata update received'); // Update metadata and control set info if (payload) { appState.update((state) => ({ ...state, ...payload, isConnected: true, isSwitchingControlSet: false // Clear switching flag })); } } handleControlsUpdate(payload: any) { console.log('Controls update received'); // Update just the controls array if (payload) { appState.update((state) => ({ ...state, controls: payload })); } } handleMappingsUpdate(payload: any) { console.log('Mappings update received'); // Update just the mappings array if (payload) { appState.update((state) => ({ ...state, mappings: payload })); } } handleControlDetails(payload: any) { console.log('Control details received:', payload); // Emit a custom event for control details if (payload) { window.dispatchEvent( new CustomEvent('control-details', { detail: payload }) ); } } handleControlSetsList(payload: any) { console.log('Control sets list received'); // Emit a custom event for control sets list if (payload) { window.dispatchEvent( new CustomEvent('control-sets-list', { detail: payload }) ); } } handleControlUpdated(payload: any) { console.log('Control updated successfully:', payload); // Don't trigger any state updates - the component already has the updated data // This just confirms the save was successful } handleMappingOperation(type: string, payload: any) { console.log(`Mapping operation successful: ${type}`, payload); // Emit an event so the control details panel can refresh its mappings window.dispatchEvent( new CustomEvent('mappings-changed', { detail: payload }) ); } handleError(payload: any) { console.error('WebSocket error:', payload); } private scheduleReconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('Max reconnection attempts reached'); return; } this.reconnectAttempts++; console.log( `Scheduling reconnect attempt ${this.reconnectAttempts} in ${this.reconnectDelay}ms` ); this.reconnectTimer = window.setTimeout(() => { this.connect(); }, this.reconnectDelay); // Exponential backoff this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // Max 30 seconds } disconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { this.ws.close(); this.ws = null; } appState.update((state) => ({ ...state, isConnected: false })); } // Check if WebSocket is connected without subscribing to store isConnected(): boolean { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; } // Send a command to the backend async sendCommand(type: string, payload?: any) { console.log(`Sending WebSocket command: ${type}`, payload); // Wait for connection if not ready if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.log('WebSocket not ready, waiting for connection...'); try { await this.waitForConnection(); } catch (error) { console.error('Failed to connect:', error); throw error; } } if (this.ws && this.ws.readyState === WebSocket.OPEN) { const message = JSON.stringify({ type, payload }); console.log('Sending message:', message); this.ws.send(message); } else { const error = new Error('WebSocket not connected after waiting'); console.error(error); throw error; } } // Wait for WebSocket to be connected private waitForConnection(timeout = 5000): Promise<void> { return new Promise((resolve, reject) => { const startTime = Date.now(); const checkConnection = () => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { resolve(); } else if (Date.now() - startTime > timeout) { reject(new Error('WebSocket connection timeout')); } else { setTimeout(checkConnection, 100); } }; checkConnection(); }); } // High-level command methods async updateControl(control: Control) { return this.sendCommand('update-control', control); } async createMapping(mapping: Mapping) { return this.sendCommand('create-mapping', mapping); } async updateMapping(mapping: Mapping) { return this.sendCommand('update-mapping', mapping); } async deleteMapping(uuid: string) { return this.sendCommand('delete-mapping', { uuid }); } async switchControlSet(path: string) { // Set switching flag to prevent redirect appState.update((state) => ({ ...state, isSwitchingControlSet: true })); return this.sendCommand('switch-control-set', { path }); } async getControlDetails(id: string) { return this.sendCommand('get-control', { id }); } async scanControlSets() { return this.sendCommand('scan-control-sets'); } send(message: any) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } else { console.warn('WebSocket not connected, cannot send message'); } } } export const wsClient = new WebSocketClient();