@zubridge/electron
Version:
A streamlined state management library for Electron applications using Zustand.
1,181 lines (1,169 loc) • 114 kB
JavaScript
// Node.js build with bundled dependencies
const require_mainThunkProcessor = require('./mainThunkProcessor-CxHMl3Vv.cjs');
let node_crypto = require("node:crypto");
let electron = require("electron");
let dequal = require("dequal");
let zod = require("zod");
//#region src/utils/errorHandling.ts
/**
* Enhanced error logging that preserves Error instances
*/
function logZubridgeError(error, additionalInfo) {
require_mainThunkProcessor.debugLog(`${error.constructor.name.replace("Error", "").toLowerCase()}:error`, `${error.name}:`, {
...error.getDetails(),
...additionalInfo
});
}
/**
* Convert error to serializable format for IPC without losing type info
*/
function serializeError(error) {
const zubridgeError = require_mainThunkProcessor.ensureZubridgeError(error);
return {
name: zubridgeError.name,
message: zubridgeError.message,
stack: zubridgeError.stack,
timestamp: zubridgeError.timestamp,
context: zubridgeError.context
};
}
//#endregion
//#region src/utils/handlers.ts
const handlerResolutionCache = /* @__PURE__ */ new WeakMap();
const CACHE_TTL_MS = 300 * 1e3;
const MAX_CACHE_SIZE = 1e3;
/**
* Gets or creates a cache Map for a specific handlers object
*/
function getCacheForHandlers(handlers) {
let cache = handlerResolutionCache.get(handlers);
if (!cache) {
cache = /* @__PURE__ */ new Map();
handlerResolutionCache.set(handlers, cache);
}
return cache;
}
/**
* Cleans up expired cache entries for a specific handlers object
*/
function cleanupExpiredCacheEntries(handlersCache) {
const now = Date.now();
let cleanedCount = 0;
for (const [key, entry] of handlersCache.entries()) if (now - entry.timestamp > CACHE_TTL_MS) {
handlersCache.delete(key);
cleanedCount++;
}
if (cleanedCount > 0) require_mainThunkProcessor.debugLog("handlers:cache", `Cleaned up ${cleanedCount} expired handler cache entries`);
}
/**
* Manages cache size to prevent memory leaks for a specific handlers object
*/
function manageCacheSize(handlersCache) {
if (handlersCache.size >= MAX_CACHE_SIZE) {
const entriesToRemove = handlersCache.size - Math.floor(MAX_CACHE_SIZE * .8);
let removedCount = 0;
for (const key of handlersCache.keys()) {
if (removedCount >= entriesToRemove) break;
handlersCache.delete(key);
removedCount++;
}
require_mainThunkProcessor.debugLog("handlers:cache", `Removed ${removedCount} handler cache entries to manage size`);
}
}
/**
* Helper function to find a case-insensitive match in an object
*/
function findCaseInsensitiveMatch(obj, key) {
if (key in obj) {
require_mainThunkProcessor.debugLog("store", `Found exact match for handler key: ${key}`);
return [key, obj[key]];
}
const keyLower = key.toLowerCase();
const matchingKey = Object.keys(obj).find((k) => k.toLowerCase() === keyLower);
if (matchingKey) {
require_mainThunkProcessor.debugLog("store", `Found case-insensitive match for handler key '${key}': ${matchingKey}`);
return [matchingKey, obj[matchingKey]];
}
require_mainThunkProcessor.debugLog("store", `No match found for handler key: ${key}`);
}
/**
* Helper function to find a handler by nested path
* Example: "counter.increment" -> obj.counter.increment
*/
function findNestedHandler(obj, path) {
try {
require_mainThunkProcessor.debugLog("store", `Resolving nested handler for path: ${path}`);
const parts = path.split(".");
let current = obj;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const matchingKey = Object.keys(current).find((k) => k.toLowerCase() === part.toLowerCase());
if (matchingKey === void 0) {
require_mainThunkProcessor.debugLog("store", `Could not find part '${part}' in path '${path}'`);
return;
}
current = current[matchingKey];
require_mainThunkProcessor.debugLog("store", `Resolved part '${part}' to '${matchingKey}', continuing resolution`);
}
if (typeof current === "function") {
require_mainThunkProcessor.debugLog("store", `Successfully resolved handler for path: ${path}`);
return current;
}
require_mainThunkProcessor.debugLog("store", `Found value for path ${path}, but it's not a function`);
return;
} catch (error) {
require_mainThunkProcessor.debugLog("store", "Error resolving nested handler:", error);
return;
}
}
/**
* Resolves a handler function from provided handlers using action type
* This handles both direct matches and nested path resolution
* Uses WeakMap-based caching to improve performance while automatically invalidating when handlers change
*/
function resolveHandler(handlers, actionType) {
require_mainThunkProcessor.debugLog("store", `Resolving handler for action type: ${actionType}`);
const handlersCache = getCacheForHandlers(handlers);
const now = Date.now();
const cachedEntry = handlersCache.get(actionType);
if (cachedEntry && now - cachedEntry.timestamp < CACHE_TTL_MS) {
require_mainThunkProcessor.debugLog("store", `Found cached handler resolution for action type: ${actionType}`);
return cachedEntry.handler;
}
require_mainThunkProcessor.debugLog("store", `Cache miss, performing handler resolution for action type: ${actionType}`);
let resolvedHandler;
const handlerMatch = findCaseInsensitiveMatch(handlers, actionType);
if (handlerMatch && typeof handlerMatch[1] === "function") {
require_mainThunkProcessor.debugLog("store", `Found direct handler match for action type: ${actionType}`);
resolvedHandler = handlerMatch[1];
} else {
require_mainThunkProcessor.debugLog("store", `No direct handler match, trying nested path resolution for: ${actionType}`);
resolvedHandler = findNestedHandler(handlers, actionType);
}
manageCacheSize(handlersCache);
handlersCache.set(actionType, {
handler: resolvedHandler,
timestamp: now
});
if (Math.random() < .01) cleanupExpiredCacheEntries(handlersCache);
return resolvedHandler;
}
//#endregion
//#region src/utils/serialization.ts
/**
* Type guard to check if a value is a Promise
*/
const isPromise = (value) => value !== null && typeof value === "object" && typeof value.then === "function";
/**
* Removes functions and non-serializable objects from a state object
* to prevent IPC serialization errors when sending between processes
*
* This implementation prevents memory leaks and stack overflow by:
* - Using WeakSet to detect circular references
* - Limiting recursion depth
* - Handling special object types (Date, RegExp, etc.)
* - Providing clear error boundaries
*
* @param state The state object to sanitize
* @param options Serialization options
* @returns A new state object with functions and non-serializable parts removed
*/
const sanitizeState = (state, options) => {
const { maxDepth = 10, replacer, filterKeys, includeNonEnumerable = false } = options || {};
const seen = /* @__PURE__ */ new WeakSet();
function serialize(value, currentDepth = 0, currentKey = "") {
if (currentDepth > maxDepth) return `[Max Depth Exceeded: ${currentKey}]`;
if (value === null || value === void 0) return value;
if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") return value;
if (typeof value === "bigint") return `${value.toString()}n`;
if (typeof value === "symbol") return `[Symbol: ${value.toString()}]`;
if (typeof value === "function") return;
if (typeof value !== "object") return `[Unknown Type: ${typeof value}]`;
if (seen.has(value)) return "[Circular Reference]";
seen.add(value);
try {
if (value instanceof Date) return value.toISOString();
if (value instanceof RegExp) return `[RegExp: ${value.toString()}]`;
if (value instanceof Error) return {
name: value.name,
message: value.message,
stack: value.stack
};
if (value instanceof Map) {
const entries = [];
for (const [k, v] of value.entries()) entries.push([serialize(k, currentDepth + 1, `${currentKey}[Map Key]`), serialize(v, currentDepth + 1, `${currentKey}[Map Value]`)]);
return {
__type: "Map",
entries
};
}
if (value instanceof Set) {
const values = [];
for (const v of value.values()) values.push(serialize(v, currentDepth + 1, `${currentKey}[Set Value]`));
return {
__type: "Set",
values
};
}
if (ArrayBuffer.isView(value)) return `[${value.constructor.name}: ${value.byteLength} bytes]`;
if (value instanceof ArrayBuffer) return `[ArrayBuffer: ${value.byteLength} bytes]`;
if (Array.isArray(value)) return value.map((item, index) => serialize(item, currentDepth + 1, `${currentKey}[${index}]`));
const result = {};
const obj = value;
let keysToProcess;
if (filterKeys) keysToProcess = filterKeys.filter((key) => key in obj);
else keysToProcess = includeNonEnumerable ? Object.getOwnPropertyNames(obj) : Object.keys(obj);
for (const key of keysToProcess) try {
const propValue = obj[key];
const keyPath = currentKey ? `${currentKey}.${key}` : key;
const valueToSerialize = replacer ? replacer(key, propValue) : propValue;
if (valueToSerialize !== void 0) {
const serializedValue = serialize(valueToSerialize, currentDepth + 1, keyPath);
if (serializedValue !== void 0) result[key] = serializedValue;
}
} catch (error) {
result[key] = `[Error accessing property: ${error instanceof Error ? error.message : String(error)}]`;
}
return result;
} catch (error) {
return `[Serialization Error: ${error instanceof Error ? error.message : String(error)}]`;
} finally {
seen.delete(value);
}
}
try {
if (!state || typeof state !== "object") return state;
return serialize(state);
} catch (error) {
require_mainThunkProcessor.debugLog("serialization:error", "Critical serialization error:", error);
return {
__serializationError: true,
message: error instanceof Error ? error.message : String(error),
originalType: typeof state
};
}
};
//#endregion
//#region src/adapters/redux.ts
/**
* 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) {
require_mainThunkProcessor.debugLog("adapters", "Creating Redux adapter", options);
return {
getState: () => store.getState(),
subscribe: (listener) => store.subscribe(() => listener(store.getState())),
processAction: (action) => {
try {
require_mainThunkProcessor.debugLog("adapters", "Redux adapter processing action:", action);
if (options?.handlers) {
require_mainThunkProcessor.debugLog("adapters", "Checking for handler in custom handlers");
const handler = resolveHandler(options.handlers, action.type);
if (handler) {
require_mainThunkProcessor.debugLog("adapters", `Found custom handler for action type: ${action.type}`);
require_mainThunkProcessor.debugLog("adapters", `Executing handler for ${action.type}, time: ${(/* @__PURE__ */ new Date()).toISOString()}`);
const startTime = Date.now();
const result = handler(action.payload);
if (isPromise(result)) {
require_mainThunkProcessor.debugLog("adapters", `Handler for ${action.type} returned a Promise, it will complete asynchronously`);
return {
isSync: false,
completion: result.then(() => {
const endTime$1 = Date.now();
require_mainThunkProcessor.debugLog("adapters", `Async handler for ${action.type} completed in ${endTime$1 - startTime}ms, time: ${(/* @__PURE__ */ new Date()).toISOString()}`);
}).catch((error) => {
const endTime$1 = Date.now();
const actionError = new require_mainThunkProcessor.ActionProcessingError(`Async handler execution failed for action ${action.type}`, action.type, "redux", {
duration: endTime$1 - startTime,
handlerName: "async_handler",
originalError: error
});
logZubridgeError(actionError);
return { error: actionError.message };
})
};
}
const endTime = Date.now();
require_mainThunkProcessor.debugLog("adapters", `Sync handler for ${action.type} completed in ${endTime - startTime}ms`);
return { isSync: true };
}
}
require_mainThunkProcessor.debugLog("adapters", `Dispatching action to Redux store: ${action.type}`);
store.dispatch(action);
return { isSync: true };
} catch (error) {
const actionError = new require_mainThunkProcessor.ActionProcessingError(`Synchronous action processing failed for action ${action.type}`, action.type, "redux", {
context: "sync_process_action",
originalError: error
});
logZubridgeError(actionError);
return {
isSync: true,
error: actionError.message
};
}
}
};
}
//#endregion
//#region src/adapters/zustand.ts
/**
* Creates a state manager adapter for Zustand stores
*/
function createZustandAdapter(store, options) {
require_mainThunkProcessor.debugLog("adapters", "Creating Zustand adapter", options);
return {
getState: () => store.getState(),
subscribe: (listener) => store.subscribe(listener),
processAction: (action) => {
try {
require_mainThunkProcessor.debugLog("adapters", "Zustand adapter processing action:", action);
if (options?.handlers) {
require_mainThunkProcessor.debugLog("adapters", "Checking for handler in custom handlers");
const handler = resolveHandler(options.handlers, action.type);
if (handler) {
require_mainThunkProcessor.debugLog("adapters", `Found custom handler for action type: ${action.type}`);
require_mainThunkProcessor.debugLog("adapters", `Executing handler for ${action.type}, time: ${(/* @__PURE__ */ new Date()).toISOString()}`);
const startTime = Date.now();
const result = handler(action.payload);
if (isPromise(result)) {
require_mainThunkProcessor.debugLog("adapters", `Handler for ${action.type} returned a Promise, it will complete asynchronously`);
const promiseId = Math.random().toString(36).substring(2, 10);
require_mainThunkProcessor.debugLog("adapters", `[ADAPTER_DEBUG] [${promiseId}] Creating async handler promise wrapper for action: ${action.type}`);
return {
isSync: false,
completion: result.then(() => {
const endTime$1 = Date.now();
require_mainThunkProcessor.debugLog("adapters", `[ADAPTER_DEBUG] [${promiseId}] Async handler promise for ${action.type} RESOLVED after ${endTime$1 - startTime}ms`);
require_mainThunkProcessor.debugLog("adapters", `Async handler for ${action.type} completed in ${endTime$1 - startTime}ms, time: ${(/* @__PURE__ */ new Date()).toISOString()}`);
require_mainThunkProcessor.debugLog("adapters", `[ADAPTER_DEBUG] [${promiseId}] STATE IS NOW UPDATED for ${action.type}`);
}).catch((error) => {
const endTime$1 = Date.now();
const actionError = new require_mainThunkProcessor.ActionProcessingError(`Async handler execution failed for action ${action.type}`, action.type, "zustand", {
promiseId,
duration: endTime$1 - startTime,
handlerName: "async_handler",
originalError: error
});
logZubridgeError(actionError);
return { error: actionError.message };
})
};
}
const endTime = Date.now();
require_mainThunkProcessor.debugLog("adapters", `Sync handler for ${action.type} completed in ${endTime - startTime}ms`);
return { isSync: true };
}
}
if (options?.reducer) {
require_mainThunkProcessor.debugLog("adapters", "Using reducer to handle action");
store.setState(options.reducer(store.getState(), action));
return { isSync: true };
}
if (action.type === "setState") {
require_mainThunkProcessor.debugLog("adapters", "Processing setState action");
store.setState(action.payload);
return { isSync: true };
}
require_mainThunkProcessor.debugLog("adapters", "Looking for action handler in store state");
const state = store.getState();
const methodMatch = findCaseInsensitiveMatch(Object.fromEntries(Object.entries(state).filter(([_, value]) => typeof value === "function")), action.type);
if (methodMatch && typeof methodMatch[1] === "function") {
require_mainThunkProcessor.debugLog("adapters", `Found direct method match in store state: ${methodMatch[0]}`);
const result = methodMatch[1](action.payload);
if (isPromise(result)) {
require_mainThunkProcessor.debugLog("adapters", `Method ${methodMatch[0]} returned a Promise, it will complete asynchronously`);
return {
isSync: false,
completion: result.then(() => {
require_mainThunkProcessor.debugLog("adapters", `Async method ${methodMatch[0]} completed`);
}).catch((error) => {
const actionError = new require_mainThunkProcessor.ActionProcessingError(`Async method execution failed for ${methodMatch[0]}`, action.type, "zustand", {
methodName: methodMatch[0],
originalError: error
});
logZubridgeError(actionError);
return { error: actionError.message };
})
};
}
return { isSync: true };
}
require_mainThunkProcessor.debugLog("adapters", "Trying nested path resolution for handler in store state");
const nestedStateHandler = findNestedHandler(state, action.type);
if (nestedStateHandler) {
require_mainThunkProcessor.debugLog("adapters", `Found nested handler in store state for: ${action.type}`);
const result = nestedStateHandler(action.payload);
if (isPromise(result)) {
require_mainThunkProcessor.debugLog("adapters", `Nested handler for ${action.type} returned a Promise, it will complete asynchronously`);
return {
isSync: false,
completion: result.then(() => {
require_mainThunkProcessor.debugLog("adapters", `Async nested handler for ${action.type} completed`);
}).catch((error) => {
const actionError = new require_mainThunkProcessor.ActionProcessingError(`Async nested handler execution failed for action ${action.type}`, action.type, "zustand", {
handlerName: "nested_handler",
originalError: error
});
logZubridgeError(actionError);
return { error: actionError.message };
})
};
}
return { isSync: true };
}
require_mainThunkProcessor.debugLog("adapters", `No handler found for action type: ${action.type}`);
return { isSync: true };
} catch (error) {
const actionError = new require_mainThunkProcessor.ActionProcessingError(`Synchronous action processing failed for action ${action.type}`, action.type, "zustand", {
context: "sync_process_action",
originalError: error
});
logZubridgeError(actionError);
return {
isSync: true,
error: actionError.message
};
}
}
};
}
//#endregion
//#region src/registry/stateManagerRegistry.ts
let stateManagerRegistry = /* @__PURE__ */ 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) {
require_mainThunkProcessor.debugLog("state-manager", "[DEBUG] getStateManager called");
if (stateManagerRegistry.has(store)) {
require_mainThunkProcessor.debugLog("state-manager", "[DEBUG] Returning cached state manager");
return stateManagerRegistry.get(store);
}
let stateManager;
if ("setState" in store) {
require_mainThunkProcessor.debugLog("state-manager", "[DEBUG] Creating Zustand adapter");
stateManager = createZustandAdapter(store, options);
} else if ("dispatch" in store) {
require_mainThunkProcessor.debugLog("state-manager", "[DEBUG] Creating Redux adapter");
stateManager = createReduxAdapter(store, options);
} else throw new Error("Unrecognized store type. Must be a Zustand StoreApi or Redux Store.");
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);
}
//#endregion
//#region src/middleware.ts
/**
* Helper to create bridge middleware options for Electron bridges
*
* This function integrates Rust-based middleware (@zubridge/middleware) with the Electron bridge.
*
* Note: You typically don't need to use this function directly. You can just pass the middleware
* instance directly to the bridge creation functions.
*
* @example
* ```typescript
* import { createZustandBridge } from '@zubridge/electron';
* import { initZubridgeMiddleware } from '@zubridge/middleware';
*
* // Initialize middleware from @zubridge/middleware (Rust implementation)
* const middleware = initZubridgeMiddleware({
* logging: { enabled: true }
* });
*
* // Create bridge with Rust middleware - just pass middleware directly
* const bridge = createZustandBridge(store, windows, {
* // Your other options
* middleware: middleware
* });
* ```
*
* @internal This function is used internally by bridge creation functions
*/
function createMiddlewareOptions(middleware) {
return {
beforeProcessAction: async (action) => {
try {
require_mainThunkProcessor.debugLog("core", "Applying middleware.processAction to action:", action);
let payloadForNapi;
if (action.payload !== void 0 && action.payload !== null) if (typeof action.payload === "string") payloadForNapi = action.payload;
else try {
payloadForNapi = JSON.stringify(action.payload);
} catch (stringifyError) {
require_mainThunkProcessor.debugLog("core", "[zubridge-electron] Error stringifying action payload for NAPI middleware:", stringifyError);
payloadForNapi = JSON.stringify({ error: "Payload stringification failed" });
}
const napiCompliantAction = {
type: action.type,
payload: payloadForNapi
};
await middleware.processAction(napiCompliantAction);
} catch (error) {
require_mainThunkProcessor.debugLog("core:error", "Error in zubridge middleware processAction:", error);
}
return action;
},
afterStateChange: async (state) => {
try {
require_mainThunkProcessor.debugLog("core", "Applying middleware.setState with updated state");
let stateJson;
try {
stateJson = JSON.stringify(state);
} catch (stringifyError) {
require_mainThunkProcessor.debugLog("core:error", "[zubridge-electron] Error stringifying state for NAPI middleware:", stringifyError);
stateJson = JSON.stringify({ error: "State stringification failed" });
}
await middleware.setState(stateJson);
} catch (error) {
require_mainThunkProcessor.debugLog("core:error", "Error in zubridge middleware setState:", error);
}
},
onBridgeDestroy: async () => {
if (middleware.destroy) try {
require_mainThunkProcessor.debugLog("core", "Destroying middleware instance");
await middleware.destroy();
} catch (error) {
require_mainThunkProcessor.debugLog("core:error", "Error destroying zubridge middleware:", error);
}
}
};
}
//#endregion
//#region src/utils/globalErrorHandlers.ts
/**
* Sets up global error handlers for the main process to catch unhandled errors
* and promise rejections that could otherwise crash the application
*/
function setupMainProcessErrorHandlers() {
process.on("unhandledRejection", (reason, promise) => {
logZubridgeError(new require_mainThunkProcessor.ResourceManagementError("Unhandled promise rejection detected", "promise", "cleanup", {
reason: reason instanceof Error ? reason.message : String(reason),
originalReason: reason,
promiseString: promise.toString()
}));
require_mainThunkProcessor.debugLog("process:error", "Unhandled promise rejection detected and logged");
});
process.on("uncaughtException", (error) => {
logZubridgeError(new require_mainThunkProcessor.ResourceManagementError("Uncaught exception detected", "process", "cleanup", {
originalError: error,
stack: error.stack
}));
require_mainThunkProcessor.debugLog("process:error", "Uncaught exception detected and logged");
setTimeout(() => {
process.exit(1);
}, 100);
});
require_mainThunkProcessor.debugLog("process", "Global error handlers setup for main process");
}
//#endregion
//#region src/utils/windows.ts
/**
* Type guard to check if an object is an Electron WebContents
*/
const isWebContents = (wrapperOrWebContents) => {
const result = wrapperOrWebContents && typeof wrapperOrWebContents === "object" && "id" in wrapperOrWebContents;
if (result) require_mainThunkProcessor.debugLog("windows", `isWebContents: TRUE for id ${wrapperOrWebContents.id}`);
else require_mainThunkProcessor.debugLog("windows", "isWebContents: FALSE", wrapperOrWebContents);
return result;
};
/**
* Type guard to check if an object is a WebContentsWrapper
*/
const isWrapper = (wrapperOrWebContents) => {
const result = wrapperOrWebContents && typeof wrapperOrWebContents === "object" && "webContents" in wrapperOrWebContents;
if (result) require_mainThunkProcessor.debugLog("windows", `isWrapper: TRUE for id ${wrapperOrWebContents.webContents?.id}`);
else require_mainThunkProcessor.debugLog("windows", "isWrapper: FALSE", wrapperOrWebContents);
return result;
};
/**
* Get the WebContents object from either a WebContentsWrapper or WebContents
*/
const getWebContents = (wrapperOrWebContents) => {
let description = "Invalid input";
if (wrapperOrWebContents && typeof wrapperOrWebContents === "object") if ("id" in wrapperOrWebContents) description = `WebContents ID: ${wrapperOrWebContents.id}`;
else if ("webContents" in wrapperOrWebContents) description = `Wrapper with WebContents ID: ${wrapperOrWebContents.webContents?.id}`;
else description = "Unknown object type";
require_mainThunkProcessor.debugLog("windows", `getWebContents called with: ${description}`);
if (isWebContents(wrapperOrWebContents)) {
require_mainThunkProcessor.debugLog("windows", `getWebContents: Returning direct WebContents with ID: ${wrapperOrWebContents.id}`);
return wrapperOrWebContents;
}
if (isWrapper(wrapperOrWebContents)) {
const webContents$1 = wrapperOrWebContents.webContents;
require_mainThunkProcessor.debugLog("windows", `getWebContents: Extracting from wrapper, ID: ${webContents$1?.id || "undefined"}`);
return webContents$1;
}
require_mainThunkProcessor.debugLog("windows", "getWebContents: Could not extract WebContents, returning undefined");
};
/**
* Check if a WebContents is destroyed
*/
const isDestroyed = (webContents$1) => {
try {
if (typeof webContents$1.isDestroyed === "function") {
const destroyed = webContents$1.isDestroyed();
require_mainThunkProcessor.debugLog("windows", `isDestroyed check for WebContents ID ${webContents$1.id}: ${destroyed}`);
return destroyed;
}
require_mainThunkProcessor.debugLog("windows", `isDestroyed: WebContents ID ${webContents$1?.id} has no isDestroyed function`);
return false;
} catch (error) {
require_mainThunkProcessor.debugLog("windows", `isDestroyed: Exception while checking ID ${webContents$1?.id}`, error);
return true;
}
};
const pendingMessages = /* @__PURE__ */ new Map();
/**
* Safely send a message to a WebContents
*/
const safelySendToWindow = (webContents$1, channel, data) => {
try {
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Attempting to send to WebContents ID ${webContents$1?.id}, channel: ${channel}`);
if (!webContents$1 || isDestroyed(webContents$1)) {
require_mainThunkProcessor.debugLog("windows", "safelySendToWindow: WebContents is undefined or destroyed, aborting send");
return false;
}
if (!(typeof webContents$1.send === "function")) {
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: WebContents ID ${webContents$1.id} missing 'send' function`);
return false;
}
const isLoading = typeof webContents$1.isLoading === "function" ? webContents$1.isLoading() : false;
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: WebContents ID ${webContents$1.id} isLoading: ${isLoading}`);
if (isLoading) {
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: WebContents ID ${webContents$1.id} is loading, queueing message for later`);
const wcId = webContents$1.id;
const queue = pendingMessages.get(wcId) ?? [];
if (!pendingMessages.has(wcId)) {
pendingMessages.set(wcId, queue);
webContents$1.once("destroyed", () => pendingMessages.delete(wcId));
webContents$1.once("did-finish-load", () => {
const messages = pendingMessages.get(wcId);
pendingMessages.delete(wcId);
if (!messages) return;
try {
if (!webContents$1.isDestroyed()) {
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Flushing ${messages.length} queued message(s) to WebContents ID ${wcId}`);
for (const msg of messages) webContents$1.send(msg.channel, msg.data);
} else require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: WebContents ID ${wcId} was destroyed before load finished`);
} catch (e) {
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Error sending delayed messages to WebContents ID ${wcId}`, e);
}
});
}
if (queue.length >= 1e3) {
queue.shift();
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: queue limit reached for WebContents ID ${wcId}, dropping oldest message`);
}
queue.push({
channel,
data
});
return true;
}
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Sending message immediately to WebContents ID ${webContents$1.id}`);
webContents$1.send(channel, data);
return true;
} catch (error) {
require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Exception while sending to WebContents ID ${webContents$1?.id}`, error);
return false;
}
};
/**
* Set up cleanup when WebContents is destroyed
*/
const setupDestroyListener = (webContents$1, cleanup) => {
try {
require_mainThunkProcessor.debugLog("windows", `setupDestroyListener: Setting up cleanup for WebContents ID ${webContents$1?.id}`);
if (typeof webContents$1.once === "function") webContents$1.once("destroyed", () => {
require_mainThunkProcessor.debugLog("windows", `WebContents ID ${webContents$1.id} destroyed, running cleanup`);
cleanup();
});
else require_mainThunkProcessor.debugLog("windows", `setupDestroyListener: WebContents ID ${webContents$1.id} missing 'once' function`);
} catch (e) {
require_mainThunkProcessor.debugLog("windows", `setupDestroyListener: Exception for WebContents ID ${webContents$1?.id}`, e);
}
};
/**
* Creates a WebContents tracker that uses WeakMap for automatic garbage collection
* but maintains a set of active IDs for tracking purposes
*/
const createWebContentsTracker = () => {
require_mainThunkProcessor.debugLog("windows", "Creating new WebContentsTracker");
const webContentsTracker = /* @__PURE__ */ new WeakMap();
const activeIds = /* @__PURE__ */ new Set();
const webContentsById = /* @__PURE__ */ new Map();
return {
track: (webContents$1) => {
if (!webContents$1) {
require_mainThunkProcessor.debugLog("windows", "track: Called with undefined WebContents");
return false;
}
if (isDestroyed(webContents$1)) {
require_mainThunkProcessor.debugLog("windows", `track: WebContents ID ${webContents$1.id} is already destroyed`);
return false;
}
const id = webContents$1.id;
const alreadyTracked = activeIds.has(id);
require_mainThunkProcessor.debugLog("windows", `track: ${alreadyTracked ? "Re-tracking" : "Adding"} WebContents ID ${id} to tracker`);
webContentsTracker.set(webContents$1, { id });
activeIds.add(id);
webContentsById.set(id, webContents$1);
if (!alreadyTracked) setupDestroyListener(webContents$1, () => {
require_mainThunkProcessor.debugLog("windows", `track: Cleanup handler for WebContents ID ${id} triggered`);
activeIds.delete(id);
webContentsById.delete(id);
});
return true;
},
untrack: (webContents$1) => {
if (!webContents$1) {
require_mainThunkProcessor.debugLog("windows", "untrack: Called with undefined WebContents");
return;
}
const id = webContents$1.id;
require_mainThunkProcessor.debugLog("windows", `untrack: Removing WebContents ID ${id} from tracker`);
webContentsTracker.delete(webContents$1);
activeIds.delete(id);
webContentsById.delete(id);
},
untrackById: (id) => {
require_mainThunkProcessor.debugLog("windows", `untrackById: Removing ID ${id} from tracker`);
activeIds.delete(id);
const webContents$1 = webContentsById.get(id);
if (webContents$1) {
require_mainThunkProcessor.debugLog("windows", `untrackById: Found and removing WebContents for ID ${id}`);
webContentsTracker.delete(webContents$1);
}
webContentsById.delete(id);
},
isTracked: (webContents$1) => {
if (!webContents$1) {
require_mainThunkProcessor.debugLog("windows", "isTracked: Called with undefined WebContents");
return false;
}
const tracked = webContents$1 && webContentsTracker.has(webContents$1) && activeIds.has(webContents$1.id);
require_mainThunkProcessor.debugLog("windows", `isTracked: WebContents ID ${webContents$1.id} tracked: ${tracked}`);
return tracked;
},
hasId: (id) => {
const has = activeIds.has(id);
require_mainThunkProcessor.debugLog("windows", `hasId: ID ${id} in tracker: ${has}`);
return has;
},
getActiveIds: () => {
const ids = [...activeIds];
require_mainThunkProcessor.debugLog("windows", `getActiveIds: Returning ${ids.length} active IDs: ${ids.join(", ")}`);
return ids;
},
getActiveWebContents: () => {
require_mainThunkProcessor.debugLog("windows", "getActiveWebContents: Collecting active WebContents");
const result = [];
for (const [id, webContents$1] of webContentsById.entries()) if (!isDestroyed(webContents$1)) {
require_mainThunkProcessor.debugLog("windows", `getActiveWebContents: Adding active WebContents ID ${id}`);
result.push(webContents$1);
} else {
require_mainThunkProcessor.debugLog("windows", `getActiveWebContents: Found destroyed WebContents ID ${id}, cleaning up`);
activeIds.delete(id);
webContentsById.delete(id);
}
require_mainThunkProcessor.debugLog("windows", `getActiveWebContents: Returning ${result.length} active WebContents`);
return result;
},
cleanup: () => {
require_mainThunkProcessor.debugLog("windows", `cleanup: Clearing all tracked WebContents (${activeIds.size} IDs)`);
activeIds.clear();
webContentsById.clear();
}
};
};
//#endregion
//#region src/utils/deepGet.ts
function deepGet(obj, key, def) {
let p;
let undef;
const path = typeof key === "string" ? key.split(".") : key;
let currentObj = obj;
for (p = 0; p < path.length; p++) currentObj = currentObj && typeof currentObj === "object" ? currentObj[path[p]] : undef;
return currentObj === undef ? def : currentObj;
}
//#endregion
//#region src/subscription/SubscriptionManager.ts
/**
* Normalizes keys for deduplication: sorts and joins with ',', or '*' for full-state.
* Returns:
* - '*' for full state subscription (when keys is undefined or contains '*')
* - [] for empty array (explicitly subscribing to nothing)
* - Array of specific keys otherwise
*/
function normalizeKeys(keys) {
require_mainThunkProcessor.debugLog("subscription", `[normalizeKeys] Input keys: ${keys ? keys.join(", ") : "undefined"}`);
if (!keys) {
require_mainThunkProcessor.debugLog("subscription", "[normalizeKeys] No keys provided, returning \"*\" (all state)");
return "*";
}
if (keys.length === 0) {
require_mainThunkProcessor.debugLog("subscription", "[normalizeKeys] Empty keys array, returning [] (no state)");
return [];
}
if (keys.includes("*")) {
require_mainThunkProcessor.debugLog("subscription", "[normalizeKeys] \"*\" found in keys, returning \"*\" (all state)");
return "*";
}
const normalized = [...new Set(keys.map((k) => k.trim()).filter((k) => k.length > 0))].sort();
require_mainThunkProcessor.debugLog("subscription", `[normalizeKeys] Normalized keys: ${normalized.join(", ")}`);
return normalized;
}
/**
* Extracts a partial state object for the given keys using deepGet.
*/
function getPartialState(state, keys) {
const normalized = normalizeKeys(keys);
if (normalized === "*") return { ...state };
if (normalized.length === 0) return {};
const result = {};
for (const key of normalized) {
const value = deepGet(state, key);
if (value !== void 0) setDeep(result, key, value);
}
return result;
}
/**
* Sets a deep value in an object at the given path (dot notation).
*/
function setDeep(obj, path, value) {
const keys = path.split(".");
let curr = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (curr[keys[i]] == null || typeof curr[keys[i]] !== "object") curr[keys[i]] = {};
else curr[keys[i]] = { ...curr[keys[i]] };
curr = curr[keys[i]];
}
curr[keys[keys.length - 1]] = value;
}
/**
* Checks if any of the subscribed keys have changed between prev and next state.
*/
function hasRelevantChange(prev, next, keys) {
require_mainThunkProcessor.debugLog("subscription", "[hasRelevantChange] Comparing states:", {
prev: prev === void 0 ? "undefined" : JSON.stringify(prev),
next: JSON.stringify(next),
keys
});
if (prev === void 0) return true;
const normalized = normalizeKeys(keys);
if (normalized === "*") {
if (prev === next) {
require_mainThunkProcessor.debugLog("subscription", "[hasRelevantChange] Full state subscription - same reference, skipping");
return false;
}
require_mainThunkProcessor.debugLog("subscription", "[hasRelevantChange] Full state subscription - notifying");
return true;
}
if (normalized.length === 0) return false;
return normalized.some((key) => {
const prevValue = deepGet(prev, key);
const nextValue = deepGet(next, key);
const changed = !(0, dequal.dequal)(prevValue, nextValue);
require_mainThunkProcessor.debugLog("subscription", `[hasRelevantChange] Comparing key ${key}:`, {
prevValue,
nextValue,
changed
});
return changed;
});
}
var SubscriptionManager = class {
subscriptions = /* @__PURE__ */ new Map();
nextSubId = 0;
generateSubId(windowId) {
return `window-${windowId}-sub-${this.nextSubId++}`;
}
/**
* Subscribe to state changes for specific keys (deep keys supported).
* Each call creates an independent subscription entry so that multiple
* subscriptions on the same window do not overwrite each other's callbacks.
*
* Returns `{ status: 'registered', unsubscribe }` when the callback was
* registered, or `{ status: 'superseded' }` when an existing '*' (all-state)
* subscription already covers this window and the requested keys are
* specific. Superseded callers should skip sending an initial-state delta —
* the existing '*' subscription already delivers state to the window.
*
* A '*' subscription replaces all prior entries for the window (both
* specific-key and prior '*' entries). Previously returned unsubscribe
* handles become no-ops — this is intentional: '*' supersedes everything.
* Callers must use the '*' subscription's unsubscribe handle to fully
* clean up; prior handles will not remove the '*' entry.
*/
subscribe(keys, callback, windowId) {
require_mainThunkProcessor.debugLog("subscription", `[subscribe] Called with keys: ${keys ? JSON.stringify(keys) : "undefined"} for window ${windowId}`);
require_mainThunkProcessor.debugLog("subscription", `[subscribe] Current subscriptions size: ${this.subscriptions.size}`);
const existingKeys = this.getCurrentSubscriptionKeys(windowId);
require_mainThunkProcessor.debugLog("subscription", `[subscribe] Existing subscriptions for window ${windowId}:`, existingKeys);
const normalized = normalizeKeys(keys);
require_mainThunkProcessor.debugLog("subscription", "[subscribe] Normalized keys:", normalized);
if (existingKeys.includes("*") && normalized !== "*") {
require_mainThunkProcessor.debugLog("subscription", `[subscribe] Window ${windowId} already has '*' subscription, keeping it`);
return { status: "superseded" };
}
const subId = this.generateSubId(windowId);
require_mainThunkProcessor.debugLog("subscription", `[subscribe] Using subscription id: ${subId}`);
if (normalized === "*") {
require_mainThunkProcessor.debugLog("subscription", `[subscribe] Setting full '*' subscription for window ${windowId}`);
for (const [id, sub] of this.subscriptions) if (sub.windowId === windowId) this.subscriptions.delete(id);
this.subscriptions.set(subId, {
keys: void 0,
callback,
windowId
});
} else {
require_mainThunkProcessor.debugLog("subscription", `[subscribe] Adding subscription for window ${windowId}:`, normalized);
this.subscriptions.set(subId, {
keys: normalized,
callback,
windowId
});
}
require_mainThunkProcessor.debugLog("subscription", `[subscribe] New subscriptions size: ${this.subscriptions.size}`);
const currentSubscriptions = this.getCurrentSubscriptionKeys(windowId);
require_mainThunkProcessor.debugLog("subscription", `[subscribe] Current subscriptions for window ${windowId}:`, currentSubscriptions);
return {
status: "registered",
unsubscribe: () => {
this.subscriptions.delete(subId);
}
};
}
/**
* Unsubscribe a window from specific keys, or all if no keys provided.
* This is a window-wide operation — it removes matching keys from all
* subscriptions for the window, regardless of which callback registered them.
* For per-subscription cleanup, use the `unsubscribe` handle returned by
* `subscribe()` instead.
*/
unsubscribe(keys, windowId) {
require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Called with keys: ${keys ? JSON.stringify(keys) : "undefined"} for window ${windowId}`);
if (keys && keys.length === 0) {
require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Empty keys array, no-op for window ${windowId}`);
return;
}
if (!keys || keys.includes("*")) {
require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Removing all subscriptions for window ${windowId}`);
for (const [id, sub] of this.subscriptions) if (sub.windowId === windowId) this.subscriptions.delete(id);
return;
}
for (const [id, sub] of this.subscriptions) {
if (sub.windowId !== windowId) continue;
if (sub.keys === void 0) {
require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Keeping '*' subscription for window ${windowId}`);
continue;
}
const remainingKeys = sub.keys.filter((key) => !keys.includes(key));
require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Remaining keys for subscription ${id}:`, remainingKeys);
if (remainingKeys.length === 0) {
require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] No keys left, removing subscription ${id}`);
this.subscriptions.delete(id);
} else this.subscriptions.set(id, {
...sub,
keys: remainingKeys
});
}
}
/**
* Notify all subscribers whose keys have changed, passing the relevant partial state.
*/
notify(prev, next) {
require_mainThunkProcessor.debugLog("subscription", `[notify] Starting notification with ${this.subscriptions.size} subscribers`);
require_mainThunkProcessor.debugLog("subscription", "[notify] States:", {
prev: prev === void 0 ? "undefined" : JSON.stringify(prev),
next: JSON.stringify(next)
});
for (const { keys, callback, windowId } of this.subscriptions.values()) {
require_mainThunkProcessor.debugLog("subscription", `[notify] Checking window ${windowId} with keys:`, keys);
if (hasRelevantChange(prev, next, keys)) {
const partialState = getPartialState(next, keys);
require_mainThunkProcessor.debugLog("subscription", `[notify] Notifying window ${windowId} with state:`, partialState);
callback(partialState, next);
} else require_mainThunkProcessor.debugLog("subscription", `[notify] No relevant changes for window ${windowId} - skipping notification`);
}
require_mainThunkProcessor.debugLog("subscription", "[notify] Notification complete");
}
/**
* For debugging: get all current subscription keys for a window.
* Aggregates keys across all subscriptions for the given window.
* Returns:
* - ['*'] for full state subscription
* - [] for no subscriptions
* - Array of specific keys otherwise
*/
getCurrentSubscriptionKeys(windowId) {
require_mainThunkProcessor.debugLog("subscription", `[getCurrentSubscriptionKeys] Looking up subscriptions for window ${windowId}`);
const allKeys = /* @__PURE__ */ new Set();
for (const sub of this.subscriptions.values()) {
if (sub.windowId !== windowId) continue;
if (sub.keys === void 0) {
require_mainThunkProcessor.debugLog("subscription", `[getCurrentSubscriptionKeys] Found "*" subscription, returning ["*"]`);
return ["*"];
}
for (const key of sub.keys) allKeys.add(key);
}
if (allKeys.size === 0) {
require_mainThunkProcessor.debugLog("subscription", `[getCurrentSubscriptionKeys] No subscriptions found for window ${windowId}`);
return [];
}
const result = [...allKeys];
require_mainThunkProcessor.debugLog("subscription", "[getCurrentSubscriptionKeys] Found specific keys:", result);
return result;
}
};
//#endregion
//#region src/bridge/ipc/validation.ts
/**
* Validation schemas for IPC payloads using Zod.
*
* These schemas provide:
* - Type-safe validation of all IPC inputs from renderer processes
* - Protection against injection attacks and DoS via length limits
* - Consistent validation across single and batch dispatch
* - Better error messages for debugging
*/
/**
* Maximum lengths for string fields to prevent DoS attacks
*/
const MAX_STRING_LENGTH = {
ACTION_TYPE: 200,
ACTION_ID: 100,
BATCH_ID: 100,
PARENT_ID: 100
};
/**
* Maximum batch size to prevent DoS attacks.
*
* This is an absolute hard limit that cannot be overridden by configuration.
*
* Rationale for 200:
* - ActionBatcher sends ~50 actions/batch in normal operation (BATCHING_DEFAULTS.maxBatchSize)
* - ActionBatcher queue limit is maxBatchSize * 4 = 200 (proven safe in production)
* - This limit provides 4x safety margin while preventing abuse from compromised renderers
* - Normal operation never approaches this limit
* - Consistent with existing architecture (4x multiplier used in ActionBatcher)
*
* Security model:
* - Normal operation: ≤50 actions/batch (ActionBatcher controlled)
* - Hard security cap: ≤200 actions/batch (Zod validation - catches malicious/buggy batches)
*/
const MAX_BATCH_SIZE = 200;
/**
* Schema for validating Action payloads from renderer
* Matches the Action type from @zubridge/types but enforces runtime validation
*/
const ActionPayloadSchema = zod.z.object({
type: zod.z.string().min(1).max(MAX_STRING_LENGTH.ACTION_TYPE),
payload: zod.z.unknown().optional(),
__id: zod.z.string().max(MAX_STRING_LENGTH.ACTION_ID).optional(),
__bypassAccessControl: zod.z.boolean().optional(),
__immediate: zod.z.boolean().optional(),
__startsThunk: zod.z.boolean().optional(),
__sourceWindowId: zod.z.number().optional()
}).strict();
/**
* Known fields in ActionPayloadSchema - these are kept for validation.
* Fields starting with __ that are NOT in this list will be stripped.
*/
const KNOWN_ACTION_FIELDS = new Set([
"type",
"payload",
"__id",
"__bypassAccessControl",
"__immediate",
"__startsThunk",
"__sourceWindowId"
]);
/**
* Strips internal __-prefixed fields from an action object before validation.
* Only strips fields that start with __ but are NOT in the known action fields list.
* Fields in the schema (like __id, __immediate) are kept for validation.
* @param action - The action object to sanitize (must be a plain object)
* @returns Action object with unknown internal fields removed, or original if not an object
*/
function stripInternalFields(action) {
if (!action || typeof action !== "object" || Array.isArray(action)) return action;
const result = {};
for (const [key, value] of Object.entries(action)) if (!key.startsWith("__") || KNOWN_ACTION_FIELDS.has(key)) result[key] = value;
return result;
}
/**
* Schema for single action dispatch payload
* Used by handleDispatch
*/
const SingleDispatchPayloadSchema = zod.z.object({
action: ActionPayloadSchema,
parentId: zod.z.string().max(MAX_STRING_LENGTH.PARENT_ID).optional()
}).strict();
/**
* Schema for individual actions within a batch
*/
const BatchActionItemSchema = zod.z.object({
action: ActionPayloadSchema,
id: zod.z.string().min(1).max(MAX_STRING_LENGTH.ACTION_ID),
parentId: zod.z.string().max(MAX_STRING_LENGTH.PARENT_ID).optional()
}).strict();
/**
* Schema for batch dispatch payload
* Used by handleBatchDispatch
*/
const BatchDispatchPayloadSchema = zod.z.object({
batchId: zod.z.string().min(1).max(MAX_STRING_LENGTH.BATCH_ID),
actions: zod.z.array(BatchActionItemSchema).min(1).max(MAX_BATCH_SIZE)
}).strict();
/**
* Validates a single action dispatch payload
* @param data - Unknown data from IPC channel
* @returns Validation result with typed data or error message
*/
function validateSingleDispatch(data) {
if (data && typeof data === "object" && "action" in data) {
const sanitizedData = {
...data,
action: stripInternalFields(data.action)
};
const result$1 = SingleDispatchPayloadSchema.safeParse(sanitizedData);
if (result$1.success) return {
success: true,
data: result$1.data
};
return {
success: false,
error: formatZodError(result$1.error),
details: result$1.error
};
}
const result = SingleDispatchPayloadSchema.safeParse(data);
if (result.success) return {
success: true,
data: result.data
};
return {
success: false,
error: formatZodError(result.error),
details: result.error
};
}
/**
* Validates a batch dispatch payload
* @param data - Unknown data from IPC channel
* @returns Validation result with typed data or error message
*/
function validateBatchDispatch(data) {
if (data && typeof data === "object" && "action