Add browser support with Socket.io transport, authentication, performance-optimized IPC, and CI/CD (#925)

This commit is contained in:
Copilot
2025-12-20 02:35:34 +01:00
committed by GitHub
parent 8285627c5f
commit 91df6de4d4
42 changed files with 2805 additions and 290 deletions

View File

@@ -0,0 +1,29 @@
// Browser-specific EventBus implementation using Socket.io
import io from 'socket.io-client'
import { SocketIOClientEventBus } from './SocketIOClientEventBus'
import { Rpc } from './Rpc'
// Get auth from sessionStorage or use empty (will show login dialog)
const username = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-username') || '' : ''
const password = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-password') || '' : ''
// Connect to the server (same origin in browser mode)
const socket = io({
auth: {
username,
password,
},
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: Infinity,
transports: ['websocket', 'polling'],
})
export const rendererEvents = new SocketIOClientEventBus(socket)
export const rendererRpc = new Rpc(rendererEvents)
// In browser mode, the backend is on the server
// For compatibility, export same instances (renderer communicates with server backend via socket)
export const backendEvents = rendererEvents
export const backendRpc = rendererRpc

View File

@@ -1,24 +1,54 @@
import { IpcMain } from 'electron'
import { IpcMain, WebContents } from 'electron'
import { Event } from '../Events'
import { EventBusInterface } from './EventBusInterface'
export class IpcMainEventBus implements EventBusInterface {
private ipc: IpcMain
private client: any
private clients: Map<number, WebContents> = new Map() // webContentsId -> WebContents
private connectionOwners: Map<string, number> = new Map() // connectionId -> webContentsId
private currentClient: WebContents | undefined
constructor(ipc: IpcMain) {
this.ipc = ipc
}
public subscribe<MessageType>(subscribeEvent: Event<MessageType>, callback: (msg: MessageType) => void) {
console.log('subscribing', subscribeEvent.topic)
this.ipc.on(subscribeEvent.topic, (event: any, arg: any) => {
this.client = event.sender
const sender = event.sender as WebContents
this.currentClient = sender
// Track the client (O(1) operation)
if (!this.clients.has(sender.id)) {
this.clients.set(sender.id, sender)
// Clean up when window is closed
sender.once('destroyed', () => {
this.clients.delete(sender.id)
// Clean up owned connections
for (const [connectionId, webContentsId] of this.connectionOwners.entries()) {
if (webContentsId === sender.id) {
this.connectionOwners.delete(connectionId)
}
}
})
}
// Track connection ownership
if (subscribeEvent.topic === 'connection/add/mqtt' && arg?.id) {
this.connectionOwners.set(arg.id, sender.id)
}
// Remove connection ownership
if (subscribeEvent.topic === 'connection/remove' && typeof arg === 'string') {
this.connectionOwners.delete(arg)
}
callback(arg)
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
console.log('unsubscribeAll', event.topic)
this.ipc.removeAllListeners(event.topic)
}
@@ -27,8 +57,44 @@ export class IpcMainEventBus implements EventBusInterface {
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
if (!this.client.isDestroyed()) {
this.client.send(event.topic, msg)
const topic = event.topic
// RPC responses go only to the requesting client
if (topic.includes('/response/')) {
if (this.currentClient && !this.currentClient.isDestroyed()) {
this.currentClient.send(topic, msg)
}
return
}
// Connection-specific events - optimized with early pattern match
if (topic.startsWith('conn/')) {
const parts = topic.split('/')
let connectionId: string | undefined
if (parts.length === 2) {
connectionId = parts[1]
} else if (parts.length === 3 && (parts[1] === 'state' || parts[1] === 'publish')) {
connectionId = parts[2]
}
if (connectionId) {
const ownerWebContentsId = this.connectionOwners.get(connectionId)
if (ownerWebContentsId !== undefined) {
const ownerClient = this.clients.get(ownerWebContentsId)
if (ownerClient && !ownerClient.isDestroyed()) {
ownerClient.send(topic, msg)
return
}
}
}
}
// All other events go to all clients
this.clients.forEach(client => {
if (!client.isDestroyed()) {
client.send(topic, msg)
}
})
}
}

View File

@@ -0,0 +1,61 @@
import { IpcMain } from 'electron'
import { Event } from '../Events'
import { EventBusInterface } from './EventBusInterface'
import { MessageCodec } from './MessageCodec'
/**
* Enhanced IPC Main Event Bus with Protobuf support
*
* This version uses binary serialization for better performance
* while maintaining backward compatibility with the old JSON-based system.
*/
export class IpcMainEventBusV2 implements EventBusInterface {
private ipc: IpcMain
private client: any
private useBinary: boolean
constructor(ipc: IpcMain, useBinary: boolean = true) {
this.ipc = ipc
this.useBinary = useBinary
}
public subscribe<MessageType>(subscribeEvent: Event<MessageType>, callback: (msg: MessageType) => void) {
console.log('subscribing', subscribeEvent.topic, this.useBinary ? '(binary)' : '(json)')
this.ipc.on(subscribeEvent.topic, (event: any, arg: any) => {
this.client = event.sender
if (this.useBinary && arg instanceof Uint8Array) {
// Binary message - decode it
const { data } = MessageCodec.decodeWithPayload<MessageType>(arg)
callback(data)
} else {
// Regular JSON message
callback(arg)
}
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
console.log('unsubscribeAll', event.topic)
this.ipc.removeAllListeners(event.topic)
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
throw new Error('Not implemented') // Todo: implement
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
if (!this.client || this.client.isDestroyed()) {
return
}
if (this.useBinary) {
// Encode as binary
const binary = MessageCodec.encode(event.topic, msg)
this.client.send(event.topic, binary)
} else {
// Send as JSON (legacy)
this.client.send(event.topic, msg)
}
}
}

View File

@@ -0,0 +1,65 @@
import { CallbackStore } from './CallbackStore'
import { EventBusInterface } from './EventBusInterface'
import { Event } from '../Events'
import { IpcRenderer } from 'electron'
import { MessageCodec } from './MessageCodec'
/**
* Enhanced IPC Renderer Event Bus with Protobuf support
*
* This version uses binary serialization for better performance
* while maintaining backward compatibility with the old JSON-based system.
*/
export class IpcRendererEventBusV2 implements EventBusInterface {
private ipc: IpcRenderer
private callbacks: Array<CallbackStore> = []
private useBinary: boolean
constructor(ipc: IpcRenderer, useBinary: boolean = true) {
this.ipc = ipc
this.useBinary = useBinary
}
public subscribe<MessageType>(event: Event<MessageType>, callback: (msg: MessageType) => void) {
const wrappedCallback = (_: any, arg: any) => {
if (this.useBinary && arg instanceof Uint8Array) {
// Binary message - decode it
const { data } = MessageCodec.decodeWithPayload<MessageType>(arg)
callback(data)
} else {
// Regular JSON message
callback(arg)
}
}
console.log('subscribing', event.topic, this.useBinary ? '(binary)' : '(json)')
this.ipc.on(event.topic, wrappedCallback)
this.callbacks.push({
callback,
wrappedCallback,
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
this.ipc.removeAllListeners(event.topic)
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
const item = this.callbacks.find(store => store.callback === callback)
if (!item) {
return
}
this.ipc.removeListener(event.topic, item.wrappedCallback)
this.callbacks = this.callbacks.filter(a => a !== item)
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
if (this.useBinary) {
// Encode as binary
const binary = MessageCodec.encode(event.topic, msg)
this.ipc.send(event.topic, binary)
} else {
// Send as JSON (legacy)
this.ipc.send(event.topic, msg)
}
}
}

View File

@@ -0,0 +1,74 @@
/**
* Binary Message Codec using Protobuf
*
* This provides efficient binary serialization for IPC messages,
* avoiding JSON stringify/parse overhead.
*/
import * as protobuf from 'protobufjs'
// Define message schema
const messageSchema = {
nested: {
mqtt: {
nested: {
Envelope: {
fields: {
topic: { type: 'string', id: 1 },
payload: { type: 'bytes', id: 2 },
},
},
},
},
},
}
// Create root from JSON schema
const root = protobuf.Root.fromJSON(messageSchema)
const Envelope = root.lookupType('mqtt.Envelope')
export interface BinaryMessage {
topic: string
payload: Uint8Array
}
export class MessageCodec {
/**
* Encode a message to binary format
*/
public static encode(topic: string, data: any): Uint8Array {
// Serialize the payload to JSON, then to bytes
const jsonString = JSON.stringify(data)
const payloadBytes = new TextEncoder().encode(jsonString)
// Create protobuf envelope
const message = Envelope.create({
topic,
payload: payloadBytes,
})
// Encode to binary
return Envelope.encode(message).finish()
}
/**
* Decode a binary message
*/
public static decode(binary: Uint8Array): BinaryMessage {
const message = Envelope.decode(binary) as any
return {
topic: message.topic,
payload: message.payload,
}
}
/**
* Decode and parse payload as JSON
*/
public static decodeWithPayload<T>(binary: Uint8Array): { topic: string; data: T } {
const { topic, payload } = this.decode(binary)
const jsonString = new TextDecoder().decode(payload)
const data = JSON.parse(jsonString)
return { topic, data }
}
}

View File

@@ -0,0 +1,42 @@
import { Socket } from 'socket.io-client'
import { CallbackStore } from './CallbackStore'
import { EventBusInterface } from './EventBusInterface'
import { Event } from '../Events'
export class SocketIOClientEventBus implements EventBusInterface {
private socket: Socket
private callbacks: Array<CallbackStore> = []
constructor(socket: Socket) {
this.socket = socket
}
public subscribe<MessageType>(event: Event<MessageType>, callback: (msg: MessageType) => void) {
const wrappedCallback = (arg: any) => {
callback(arg)
}
console.log('subscribing', event.topic)
this.socket.on(event.topic, wrappedCallback)
this.callbacks.push({
callback,
wrappedCallback,
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
this.socket.removeAllListeners(event.topic)
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
const item = this.callbacks.find(store => store.callback === callback)
if (!item) {
return
}
this.socket.off(event.topic, item.wrappedCallback)
this.callbacks = this.callbacks.filter(a => a !== item)
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
this.socket.emit(event.topic, msg)
}
}

View File

@@ -0,0 +1,274 @@
import { Server as SocketIOServer, Socket } from 'socket.io'
import { Event } from '../Events'
import { EventBusInterface } from './EventBusInterface'
import Debug from 'debug'
const debug = Debug('mqtt-explorer:socketio')
const debugConnect = Debug('mqtt-explorer:socketio:connect')
const debugDisconnect = Debug('mqtt-explorer:socketio:disconnect')
const debugSubscriptions = Debug('mqtt-explorer:socketio:subscriptions')
const debugConnections = Debug('mqtt-explorer:socketio:connections')
const debugEmit = Debug('mqtt-explorer:socketio:emit')
interface SocketSubscription {
topic: string
handler: (arg: any) => void
}
export class SocketIOServerEventBus implements EventBusInterface {
private io: SocketIOServer
private clients: Map<string, Socket> = new Map() // socketId -> Socket
// Global handlers that apply to ALL sockets (like RPC endpoints)
private globalHandlers: Map<string, (socket: Socket, arg: any) => void> = new Map()
// Per-socket subscriptions for cleanup
private socketSubscriptions: Map<string, SocketSubscription[]> = new Map()
// Track which socket is currently processing a request
private currentSocket: Socket | undefined
// Map connectionId -> socketId to route messages to correct client
private connectionOwners: Map<string, string> = new Map()
// Track which connections to close when a socket disconnects
private socketConnections: Map<string, Set<string>> = new Map()
constructor(io: SocketIOServer) {
this.io = io
// Register connection handler once
this.io.on('connection', socket => {
debugConnect('Client connected: %s', socket.id)
this.clients.set(socket.id, socket)
this.socketSubscriptions.set(socket.id, [])
this.socketConnections.set(socket.id, new Set())
// Register all global handlers on this socket
this.globalHandlers.forEach((handler, topic) => {
this.registerHandlerOnSocket(socket, topic, handler)
})
// Log connection metrics
this.logConnectionMetrics('connect', socket.id)
socket.on('disconnect', () => {
debugDisconnect('Client disconnected: %s', socket.id)
this.cleanupSocket(socket)
this.clients.delete(socket.id)
this.logConnectionMetrics('disconnect', socket.id)
})
})
}
private logConnectionMetrics(event: 'connect' | 'disconnect', socketId: string) {
const totalClients = this.clients.size
const totalSubscriptions = Array.from(this.socketSubscriptions.values()).reduce((sum, subs) => sum + subs.length, 0)
const totalConnections = this.connectionOwners.size
const socketSubs = this.socketSubscriptions.get(socketId)?.length || 0
const socketConns = this.socketConnections.get(socketId)?.size || 0
debug(
'[%s] clients=%d subscriptions=%d mqttConns=%d | socket[%s]: subs=%d conns=%d',
event,
totalClients,
totalSubscriptions,
totalConnections,
socketId.substring(0, 8),
socketSubs,
socketConns
)
debugSubscriptions(
'Total subscriptions: %d across %d sockets (avg: %d per socket)',
totalSubscriptions,
totalClients,
totalClients > 0 ? Math.round(totalSubscriptions / totalClients) : 0
)
debugConnections(
'MQTT connections: %d total, %d owned by socket %s',
totalConnections,
socketConns,
socketId.substring(0, 8)
)
}
private registerHandlerOnSocket(socket: Socket, topic: string, handler: (socket: Socket, arg: any) => void) {
const wrappedHandler = (arg: any) => {
this.currentSocket = socket
// Track connection ownership when a connection is added
if (topic === 'connection/add/mqtt' && arg?.id) {
this.connectionOwners.set(arg.id, socket.id)
const socketConns = this.socketConnections.get(socket.id)
if (socketConns) {
socketConns.add(arg.id)
}
debugConnections(
'Connection %s owned by socket %s (total: %d)',
arg.id,
socket.id.substring(0, 8),
socketConns?.size || 0
)
}
// Remove connection ownership when a connection is removed
if (topic === 'connection/remove' && typeof arg === 'string') {
this.connectionOwners.delete(arg)
const socketConns = this.socketConnections.get(socket.id)
if (socketConns) {
socketConns.delete(arg)
}
debugConnections(
'Connection %s removed (socket %s remaining: %d)',
arg,
socket.id.substring(0, 8),
socketConns?.size || 0
)
}
handler(socket, arg)
}
socket.on(topic, wrappedHandler)
// Track subscription for cleanup
const subscriptions = this.socketSubscriptions.get(socket.id)
if (subscriptions) {
subscriptions.push({ topic, handler: wrappedHandler })
}
}
private cleanupSocket(socket: Socket) {
debugDisconnect('Cleaning up socket %s', socket.id)
// Remove all event listeners for this socket
const subscriptions = this.socketSubscriptions.get(socket.id)
if (subscriptions) {
subscriptions.forEach(({ topic, handler }) => {
socket.off(topic, handler)
})
this.socketSubscriptions.delete(socket.id)
debugSubscriptions('Removed %d subscriptions for socket %s', subscriptions.length, socket.id.substring(0, 8))
}
// Close all MQTT connections owned by this socket
const ownedConnections = this.socketConnections.get(socket.id)
if (ownedConnections && ownedConnections.size > 0) {
debugConnections(
'Socket %s owned %d connections, requesting cleanup',
socket.id.substring(0, 8),
ownedConnections.size
)
// Emit connection/remove for each owned connection
// This will be handled by ConnectionManager to actually close the MQTT connection
ownedConnections.forEach(connectionId => {
debugConnections('Auto-closing connection %s (owner disconnected)', connectionId)
// Simulate a remove request from this socket
const removeHandler = this.globalHandlers.get('connection/remove')
if (removeHandler) {
this.currentSocket = socket
removeHandler(socket, connectionId)
}
this.connectionOwners.delete(connectionId)
})
this.socketConnections.delete(socket.id)
}
// Remove from clients set
this.clients.delete(socket.id)
// Clear current socket if it was this one
if (this.currentSocket === socket) {
this.currentSocket = undefined
}
debugDisconnect('Cleanup complete for socket %s', socket.id.substring(0, 8))
}
public subscribe<MessageType>(subscribeEvent: Event<MessageType>, callback: (msg: MessageType) => void) {
const handler = (socket: Socket, arg: any) => {
this.currentSocket = socket
callback(arg)
}
// Store as global handler
this.globalHandlers.set(subscribeEvent.topic, handler)
// Register on all currently connected clients
this.clients.forEach(client => {
this.registerHandlerOnSocket(client, subscribeEvent.topic, handler)
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
// Remove from global handlers
this.globalHandlers.delete(event.topic)
// Remove from all sockets
this.clients.forEach(client => {
const subscriptions = this.socketSubscriptions.get(client.id)
if (subscriptions) {
const toRemove = subscriptions.filter(s => s.topic === event.topic)
toRemove.forEach(({ handler }) => {
client.off(event.topic, handler)
})
// Update subscriptions list
this.socketSubscriptions.set(
client.id,
subscriptions.filter(s => s.topic !== event.topic)
)
}
})
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
throw new Error('Not implemented - use unsubscribeAll instead')
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
const topic = event.topic
// Check if this is an RPC response (contains /response/ in topic)
if (topic.includes('/response/')) {
if (this.currentSocket && this.currentSocket.connected) {
this.currentSocket.emit(topic, msg)
}
return
}
// Check if this is a connection-specific event - optimized with early pattern match
// Patterns: conn/${connectionId}, conn/state/${connectionId}, conn/publish/${connectionId}
if (topic.startsWith('conn/')) {
const parts = topic.split('/')
let connectionId: string | undefined
if (parts.length === 2) {
// conn/${connectionId}
connectionId = parts[1]
} else if (parts.length === 3 && (parts[1] === 'state' || parts[1] === 'publish')) {
// conn/state/${connectionId} or conn/publish/${connectionId}
connectionId = parts[2]
}
if (connectionId) {
const ownerSocketId = this.connectionOwners.get(connectionId)
if (ownerSocketId) {
const ownerSocket = this.clients.get(ownerSocketId)
if (ownerSocket && ownerSocket.connected) {
ownerSocket.emit(topic, msg)
return
}
}
}
}
// All other events go to all clients
this.io.emit(topic, msg)
}
}

71
events/EventsV2.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Simplified Event System V2
*
* This provides a simpler, more type-safe way to define and use events.
* Instead of factory functions like makeConnectionStateEvent(id),
* you can now use: Events.connectionState(id)
*/
import { Base64MessageDTO } from '../backend/src/Model/Base64Message'
import { DataSourceState, MqttOptions } from '../backend/src/DataSource'
import { UpdateInfo } from 'builder-util-runtime'
import { RpcEvent } from './EventSystem/Rpc'
export type EventV2<MessageType> = {
topic: string
}
// Simple event definitions (no parameters)
export const Events = {
// Connection management
addMqttConnection: { topic: 'connection/add/mqtt' } as EventV2<AddMqttConnectionV2>,
removeConnection: { topic: 'connection/remove' } as EventV2<string>,
updateAvailable: { topic: 'app/update/available' } as EventV2<UpdateInfo>,
// Parameterized events (for connection-specific events)
connectionState: (connectionId: string) => ({ topic: `conn/state/${connectionId}` }) as EventV2<DataSourceState>,
connectionMessage: (connectionId: string) => ({ topic: `conn/${connectionId}` }) as EventV2<MqttMessageV2>,
publish: (connectionId: string) => ({ topic: `conn/publish/${connectionId}` }) as EventV2<MqttMessageV2>,
}
// RPC Events - type-safe request/response patterns
export const RpcEvents = {
getAppVersion: { topic: 'getAppVersion' } as RpcEvent<void, string>,
writeToFile: { topic: 'writeFile' } as RpcEvent<{ filePath: string; data: string; encoding?: string }, void>,
readFromFile: { topic: 'readFromFile' } as RpcEvent<{ filePath: string; encoding?: string }, Buffer>,
openDialog: { topic: 'openDialog' } as RpcEvent<OpenDialogOptionsV2, OpenDialogReturnValueV2>,
saveDialog: { topic: 'saveDialog' } as RpcEvent<SaveDialogOptionsV2, SaveDialogReturnValueV2>,
uploadCertificate: { topic: 'uploadCertificate' } as RpcEvent<CertificateUploadRequest, CertificateUploadResponse>,
}
// Type definitions
export interface AddMqttConnectionV2 {
id: string
options: MqttOptions
}
export interface MqttMessageV2 {
topic: string
payload: Base64MessageDTO | null
qos: 0 | 1 | 2
retain: boolean
messageId: number | undefined
}
export interface CertificateUploadRequest {
filename: string
data: string // base64 encoded
}
export interface CertificateUploadResponse {
name: string
data: string // base64 encoded
}
// Electron dialog types (re-exported for convenience)
import { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
export type OpenDialogOptionsV2 = OpenDialogOptions
export type OpenDialogReturnValueV2 = OpenDialogReturnValue
export type SaveDialogOptionsV2 = SaveDialogOptions
export type SaveDialogReturnValueV2 = SaveDialogReturnValue

View File

@@ -1,6 +1,7 @@
import { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
import { RpcEvent } from './EventSystem/Rpc'
// Legacy functions - use RpcEvents from EventsV2.ts for new code
export function makeOpenDialogRpc(): RpcEvent<OpenDialogOptions, OpenDialogReturnValue> {
return {
topic: 'openDialog',

View File

@@ -1,4 +1,5 @@
export * from './Events'
export * from './EventsV2'
export * from './EventSystem/EventDispatcher'
export * from './EventSystem/EventBus'
export * from './EventSystem/EventBusInterface'