@zubridge/electron
Version:
A streamlined state management library for Electron applications using Zustand.
645 lines (632 loc) • 22.7 kB
JavaScript
;
var electron = require('electron');
/**
* Constants used for IPC communication between main and renderer processes.
* These are internal to the Zubridge electron implementation.
*/
var IpcChannel;
(function (IpcChannel) {
/** Channel for subscribing to state updates */
IpcChannel["SUBSCRIBE"] = "__zubridge_state_update";
/** Channel for getting the current state */
IpcChannel["GET_STATE"] = "__zubridge_get_initial_state";
/** Channel for dispatching actions */
IpcChannel["DISPATCH"] = "__zubridge_dispatch_action";
/** Channel for acknowledging action dispatches */
IpcChannel["DISPATCH_ACK"] = "__zubridge_dispatch_ack";
})(IpcChannel || (IpcChannel = {}));
/**
* Helper function to find a case-insensitive match in an object
*/
function findCaseInsensitiveMatch(obj, key) {
// Try exact match first
if (key in obj) {
return [key, obj[key]];
}
// Try case-insensitive match
const keyLower = key.toLowerCase();
const matchingKey = Object.keys(obj).find((k) => k.toLowerCase() === keyLower);
if (matchingKey) {
return [matchingKey, obj[matchingKey]];
}
return undefined;
}
/**
* Helper function to find a handler by nested path
* Example: "counter.increment" -> obj.counter.increment
*/
function findNestedHandler(obj, path) {
try {
const parts = path.split('.');
let current = obj;
// Navigate through each part of the path
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
// Case-insensitive comparison for each level
const keys = Object.keys(current);
const matchingKey = keys.find((k) => k.toLowerCase() === part.toLowerCase());
if (matchingKey === undefined) {
return undefined;
}
current = current[matchingKey];
}
return typeof current === 'function' ? current : undefined;
}
catch (error) {
console.error('Error resolving nested handler:', error);
return undefined;
}
}
/**
* Resolves a handler function from provided handlers using action type
* This handles both direct matches and nested path resolution
*/
function resolveHandler(handlers, actionType) {
// Try direct match with handlers
const handlerMatch = findCaseInsensitiveMatch(handlers, actionType);
if (handlerMatch && typeof handlerMatch[1] === 'function') {
return handlerMatch[1];
}
// Try nested path resolution in handlers
return findNestedHandler(handlers, actionType);
}
/**
* Creates a state manager adapter for Zustand stores
*/
function createZustandAdapter(store, options) {
return {
getState: () => store.getState(),
subscribe: (listener) => store.subscribe(listener),
processAction: (action) => {
try {
// First check if we have a custom handler for this action type
if (options?.handlers) {
// Try to resolve a handler for this action type
const handler = resolveHandler(options.handlers, action.type);
if (handler) {
handler(action.payload);
return;
}
}
// Next check if we have a reducer
if (options?.reducer) {
store.setState(options.reducer(store.getState(), action));
return;
}
// Handle built-in actions
if (action.type === 'setState') {
store.setState(action.payload);
}
else {
// Check for a matching method in the store state
const state = store.getState();
// Try direct match with state functions
const methodMatch = findCaseInsensitiveMatch(Object.fromEntries(Object.entries(state).filter(([_, value]) => typeof value === 'function')), action.type);
if (methodMatch && typeof methodMatch[1] === 'function') {
methodMatch[1](action.payload);
return;
}
// Try nested path resolution in state
const nestedStateHandler = findNestedHandler(state, action.type);
if (nestedStateHandler) {
nestedStateHandler(action.payload);
return;
}
}
}
catch (error) {
console.error('Error processing action:', error);
}
},
};
}
/**
* Creates a state manager adapter for Redux stores
*
* This adapter connects a Redux store to the Zubridge bridge,
* allowing it to be used with the Electron IPC system.
*/
function createReduxAdapter(store, options) {
return {
getState: () => store.getState(),
subscribe: (listener) => store.subscribe(() => listener(store.getState())),
processAction: (action) => {
try {
// First check if we have a custom handler for this action type
if (options?.handlers) {
// Try to resolve a handler for this action type
const handler = resolveHandler(options.handlers, action.type);
if (handler) {
handler(action.payload);
return;
}
}
// For Redux, we dispatch all actions to the store
// with our standard Action format
store.dispatch(action);
}
catch (error) {
console.error('Error processing Redux action:', error);
}
},
};
}
// WeakMap allows stores to be garbage collected when no longer referenced
// Use a variable reference so we can replace it in tests
let stateManagerRegistry = new WeakMap();
/**
* Gets a state manager for the given store, creating one if it doesn't exist
* @internal This is used by createDispatch and createCoreBridge
*/
function getStateManager(store, options) {
// Check if we already have a state manager for this store
if (stateManagerRegistry.has(store)) {
return stateManagerRegistry.get(store);
}
// Create a new state manager based on store type
let stateManager;
if ('setState' in store) {
// It's a Zustand store
stateManager = createZustandAdapter(store, options);
}
else if ('dispatch' in store) {
// It's a Redux store
stateManager = createReduxAdapter(store, options);
}
else {
throw new Error('Unrecognized store type. Must be a Zustand StoreApi or Redux Store.');
}
// Cache the state manager
stateManagerRegistry.set(store, stateManager);
return stateManager;
}
/**
* Removes a state manager from the registry
* Useful when cleaning up to prevent memory leaks in long-running applications
*/
function removeStateManager(store) {
stateManagerRegistry.delete(store);
}
/**
* Type guard to check if an object is an Electron WebContents
*/
const isWebContents = (wrapperOrWebContents) => {
return wrapperOrWebContents && typeof wrapperOrWebContents === 'object' && 'id' in wrapperOrWebContents;
};
/**
* Type guard to check if an object is a WebContentsWrapper
*/
const isWrapper = (wrapperOrWebContents) => {
return wrapperOrWebContents && typeof wrapperOrWebContents === 'object' && 'webContents' in wrapperOrWebContents;
};
/**
* Get the WebContents object from either a WebContentsWrapper or WebContents
*/
const getWebContents = (wrapperOrWebContents) => {
if (wrapperOrWebContents && typeof wrapperOrWebContents === 'object') {
if ('id' in wrapperOrWebContents) {
`WebContents ID: ${wrapperOrWebContents.id}`;
}
else if ('webContents' in wrapperOrWebContents) {
`Wrapper with WebContents ID: ${wrapperOrWebContents.webContents?.id}`;
}
else ;
}
if (isWebContents(wrapperOrWebContents)) {
return wrapperOrWebContents;
}
if (isWrapper(wrapperOrWebContents)) {
const webContents = wrapperOrWebContents.webContents;
return webContents;
}
return undefined;
};
/**
* Check if a WebContents is destroyed
*/
const isDestroyed = (webContents) => {
try {
if (typeof webContents.isDestroyed === 'function') {
const destroyed = webContents.isDestroyed();
return destroyed;
}
return false;
}
catch (error) {
return true;
}
};
/**
* Safely send a message to a WebContents
*/
const safelySendToWindow = (webContents, channel, data) => {
try {
if (!webContents || isDestroyed(webContents)) {
return false;
}
// Type check for WebContents API
const hasWebContentsAPI = typeof webContents.send === 'function';
if (!hasWebContentsAPI) {
return false;
}
// Check if isLoading is a function before calling it
const isLoading = typeof webContents.isLoading === 'function' ? webContents.isLoading() : false;
if (isLoading) {
webContents.once('did-finish-load', () => {
try {
if (!webContents.isDestroyed()) {
webContents.send(channel, data);
}
}
catch (e) { }
});
return true;
}
webContents.send(channel, data);
return true;
}
catch (error) {
return false;
}
};
/**
* Set up cleanup when WebContents is destroyed
*/
const setupDestroyListener = (webContents, cleanup) => {
try {
if (typeof webContents.once === 'function') {
webContents.once('destroyed', () => {
cleanup();
});
}
}
catch (e) { }
};
/**
* Creates a WebContents tracker that uses WeakMap for automatic garbage collection
* but maintains a set of active IDs for tracking purposes
*/
const createWebContentsTracker = () => {
// WeakMap for the primary storage - won't prevent garbage collection
const webContentsTracker = new WeakMap();
// Set to track active subscription IDs (not object references)
const activeIds = new Set();
// Strong reference map of WebContents by ID - we need this to retrieve active WebContents
// This will be maintained alongside the WeakMap
const webContentsById = new Map();
return {
track: (webContents) => {
if (!webContents) {
return false;
}
if (isDestroyed(webContents)) {
return false;
}
const id = webContents.id;
webContentsTracker.set(webContents, { id });
activeIds.add(id);
webContentsById.set(id, webContents);
// Set up the destroyed listener for cleanup
setupDestroyListener(webContents, () => {
activeIds.delete(id);
webContentsById.delete(id);
});
return true;
},
untrack: (webContents) => {
if (!webContents) {
return;
}
const id = webContents.id;
// Explicitly delete from all tracking structures
webContentsTracker.delete(webContents);
activeIds.delete(id);
webContentsById.delete(id);
},
untrackById: (id) => {
activeIds.delete(id);
const webContents = webContentsById.get(id);
if (webContents) {
webContentsTracker.delete(webContents);
}
webContentsById.delete(id);
},
isTracked: (webContents) => {
if (!webContents) {
return false;
}
return webContents && webContentsTracker.has(webContents) && activeIds.has(webContents.id);
},
hasId: (id) => {
return activeIds.has(id);
},
getActiveIds: () => {
const ids = [...activeIds];
return ids;
},
getActiveWebContents: () => {
const result = [];
// Filter out any destroyed WebContents that might still be in our map
for (const [id, webContents] of webContentsById.entries()) {
if (!isDestroyed(webContents)) {
result.push(webContents);
}
else {
activeIds.delete(id);
webContentsById.delete(id);
}
}
return result;
},
cleanup: () => {
activeIds.clear();
webContentsById.clear();
},
};
};
/**
* Prepare WebContents objects from an array of wrappers or WebContents
*/
const prepareWebContents = (wrappers) => {
if (!wrappers || !Array.isArray(wrappers)) {
return [];
}
const result = [];
for (const wrapper of wrappers) {
const webContents = getWebContents(wrapper);
if (webContents && !isDestroyed(webContents)) {
result.push(webContents);
}
}
return result;
};
/**
* Removes functions and non-serializable objects from a state object
* to prevent IPC serialization errors when sending between processes
*
* @param state The state object to sanitize
* @returns A new state object with functions and non-serializable parts removed
*/
const sanitizeState = (state) => {
if (!state || typeof state !== 'object')
return state;
const safeState = {};
for (const key in state) {
const value = state[key];
// Skip functions which cannot be cloned over IPC
if (typeof value !== 'function') {
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Recursively sanitize nested objects
safeState[key] = sanitizeState(value);
}
else {
safeState[key] = value;
}
}
}
return safeState;
};
/**
* Creates a core bridge between the main process and renderer processes
* This implements the Zubridge Electron backend contract without requiring a specific state management library
*/
function createCoreBridge(stateManager, initialWrappers) {
// Tracker for WebContents using WeakMap for automatic garbage collection
const tracker = createWebContentsTracker();
// Initialize with initial wrappers
if (initialWrappers) {
const initialWebContents = prepareWebContents(initialWrappers);
for (const webContents of initialWebContents) {
tracker.track(webContents);
}
}
// Handle dispatch events from renderers
electron.ipcMain.on(IpcChannel.DISPATCH, (event, action) => {
try {
// Process the action through our state manager
stateManager.processAction(action);
// Send acknowledgment back to the sender if the action has an ID
if (action.id) {
event.sender.send(IpcChannel.DISPATCH_ACK, action.id);
}
}
catch (error) {
console.error('Error handling dispatch:', error);
// Even on error, we should acknowledge the action was processed
if (action.id) {
event.sender.send(IpcChannel.DISPATCH_ACK, action.id);
}
}
});
// Handle getState requests from renderers
electron.ipcMain.handle(IpcChannel.GET_STATE, () => {
try {
return sanitizeState(stateManager.getState());
}
catch (error) {
console.error('Error handling getState:', error);
return {};
}
});
// Subscribe to state manager changes and broadcast to subscribed windows
const stateManagerUnsubscribe = stateManager.subscribe((state) => {
try {
const activeIds = tracker.getActiveIds();
if (activeIds.length === 0) {
return;
}
// Sanitize state before sending
const safeState = sanitizeState(state);
// Get active WebContents from our tracker
const activeWebContents = tracker.getActiveWebContents();
// Send updates to all active WebContents that were explicitly subscribed
for (const webContents of activeWebContents) {
safelySendToWindow(webContents, IpcChannel.SUBSCRIBE, safeState);
}
}
catch (error) {
console.error('Error in state subscription handler:', error);
}
});
// Add new windows to tracking and subscriptions
const subscribe = (newWrappers) => {
const addedWebContents = [];
// Handle invalid input cases
if (!newWrappers || !Array.isArray(newWrappers)) {
return { unsubscribe: () => { } };
}
// Get WebContents from wrappers and track them
for (const wrapper of newWrappers) {
const webContents = getWebContents(wrapper);
if (!webContents || isDestroyed(webContents)) {
continue;
}
// Track the WebContents
if (tracker.track(webContents)) {
addedWebContents.push(webContents);
// Send initial state
const currentState = sanitizeState(stateManager.getState());
safelySendToWindow(webContents, IpcChannel.SUBSCRIBE, currentState);
}
}
// Return an unsubscribe function
return {
unsubscribe: () => {
for (const webContents of addedWebContents) {
tracker.untrack(webContents);
}
},
};
};
// Remove windows from subscriptions
const unsubscribe = (unwrappers) => {
if (!unwrappers) {
// If no wrappers are provided, unsubscribe all
tracker.cleanup();
return;
}
for (const wrapper of unwrappers) {
const webContents = getWebContents(wrapper);
if (webContents) {
tracker.untrack(webContents);
}
}
};
// Get the list of currently subscribed window IDs
const getSubscribedWindows = () => {
return tracker.getActiveIds();
};
// Cleanup function to remove all listeners
const destroy = () => {
stateManagerUnsubscribe();
electron.ipcMain.removeHandler(IpcChannel.GET_STATE);
// We can't remove the "on" listener cleanly in Electron,
// but we can ensure we don't process any more dispatches
tracker.cleanup();
};
return {
subscribe,
unsubscribe,
getSubscribedWindows,
destroy,
};
}
/**
* Internal utility to create a bridge from a store
* This is used by createZustandBridge and createReduxBridge
* @internal
*/
function createBridgeFromStore(store, windows, options) {
// Get or create a state manager for the store
const stateManager = getStateManager(store, options);
// Create the bridge using the state manager
return createCoreBridge(stateManager, windows);
}
/**
* Implementation that handles both overloads
*/
function createDispatch(storeOrManager, options) {
// Get or create a state manager for the store or use the provided one
const stateManager = 'processAction' in storeOrManager
? storeOrManager
: getStateManager(storeOrManager, options);
const dispatch = (actionOrThunk, payload) => {
try {
if (typeof actionOrThunk === 'function') {
// Handle thunks
return actionOrThunk(() => stateManager.getState(), dispatch);
}
else if (typeof actionOrThunk === 'string') {
// Handle string action types with payload
stateManager.processAction({
type: actionOrThunk,
payload,
});
}
else if (actionOrThunk && typeof actionOrThunk === 'object') {
// Handle action objects
stateManager.processAction(actionOrThunk);
}
else {
console.error('Invalid action or thunk:', actionOrThunk);
}
}
catch (err) {
console.error('Error in dispatch:', err);
}
};
return dispatch;
}
/**
* Creates a bridge between a Zustand store and the renderer process
*/
function createZustandBridge(store, windows, options) {
// Create the core bridge with the store
const coreBridge = createBridgeFromStore(store, windows, options);
// Create the dispatch function with the same store
const dispatchFn = createDispatch(store, options);
// Return bridge with all functionality
return {
subscribe: coreBridge.subscribe,
unsubscribe: coreBridge.unsubscribe,
getSubscribedWindows: coreBridge.getSubscribedWindows,
destroy: () => {
coreBridge.destroy();
// Clean up the state manager from the registry
removeStateManager(store);
},
dispatch: dispatchFn,
};
}
/**
* Creates a bridge between a Redux store and the renderer process
*/
function createReduxBridge(store, windows, options) {
// Create the core bridge with the store
const coreBridge = createBridgeFromStore(store, windows, options);
// Create the dispatch function with the same store
const dispatchFn = createDispatch(store, options);
// Return bridge with all functionality
return {
subscribe: coreBridge.subscribe,
unsubscribe: coreBridge.unsubscribe,
getSubscribedWindows: coreBridge.getSubscribedWindows,
destroy: () => {
coreBridge.destroy();
// Clean up the state manager from the registry
removeStateManager(store);
},
dispatch: dispatchFn,
};
}
/**
* Legacy bridge alias for backward compatibility
* @deprecated This is now an alias for createZustandBridge and uses the new IPC channels.
* Please update your code to use createZustandBridge directly in the future.
*/
const mainZustandBridge = createZustandBridge;
exports.createCoreBridge = createCoreBridge;
exports.createDispatch = createDispatch;
exports.createReduxBridge = createReduxBridge;
exports.createZustandBridge = createZustandBridge;
exports.mainZustandBridge = mainZustandBridge;