UNPKG

@zubridge/electron

Version:

A streamlined state management library for Electron applications using Zustand.

1,477 lines (1,459 loc) 70.1 kB
let electron = require("electron"); let zod = require("zod"); //#region ../core/dist/utils/debug.js /** * Debug logging utility for Zubridge packages * Supports optional weald and @wdio/logger dependencies that are loaded dynamically * Falls back to console logging in sandbox or when dependencies are unavailable */ const isWDIOTest = () => { if (typeof process === "undefined") return false; return typeof globalThis.browser !== "undefined" || process.env.WDIO === "true" || !!process.env.WDIO_LOG_LEVEL; }; const isDebugEnabled = () => { if (typeof process !== "undefined" && process.env?.DEBUG) { const debugPattern = process.env.DEBUG; return debugPattern === "*" || debugPattern.includes("zubridge"); } if (typeof localStorage !== "undefined") try { const debug = localStorage.getItem("debug"); return debug === "*" || debug?.includes("zubridge") || false; } catch { return false; } return false; }; let wdioLogger = null; let wealdDebug = null; let loggersInitialized = false; /** * Attempt to load optional logging dependencies dynamically */ async function initializeOptionalLoggers() { if (loggersInitialized) return; loggersInitialized = true; if (isWDIOTest()) try { const wdioModule = await import("@wdio/logger"); wdioLogger = (wdioModule.default || wdioModule)("zubridge"); } catch {} if (!isWDIOTest()) try { const wealdModule = await import("weald"); wealdDebug = wealdModule.default || wealdModule; } catch {} } initializeOptionalLoggers(); /** * Create a debug function for a specific namespace */ function createDebugger(namespace) { return (...args) => { if (!isDebugEnabled()) return; if (wdioLogger) { if (namespace.endsWith(":error")) wdioLogger.error(namespace, ...args); else if (namespace.endsWith(":warn")) wdioLogger.warn(namespace, ...args); else if (namespace.endsWith(":info")) wdioLogger.info(namespace, ...args); else wdioLogger.debug(namespace, ...args); return; } if (wealdDebug) { wealdDebug(namespace)(...args); return; } console.log(`[${namespace}]`, ...args); }; } const debuggers = { core: createDebugger("zubridge:core"), ipc: createDebugger("zubridge:ipc"), store: createDebugger("zubridge:store"), adapters: createDebugger("zubridge:adapters"), windows: createDebugger("zubridge:windows"), serialization: createDebugger("zubridge:serialization") }; const dynamicDebuggers = /* @__PURE__ */ new Map(); /** * Get or create a debugger for the specified area */ function getDebugger(area) { if (area in debuggers) return debuggers[area]; if (!dynamicDebuggers.has(area)) dynamicDebuggers.set(area, createDebugger(`zubridge:${area}`)); const debugFn = dynamicDebuggers.get(area); if (!debugFn) throw new Error(`Failed to create debugger for area: ${area}`); return debugFn; } /** * Log a debug message */ function debugLog(area, ...args) { getDebugger(area)(...args); } //#endregion //#region src/batching/types.ts const BATCHING_DEFAULTS = { windowMs: 16, maxBatchSize: 50, priorityFlushThreshold: 80, ackTimeoutMs: typeof process !== "undefined" && process.platform === "linux" ? 6e4 : 3e4 }; /** * Priority levels for action scheduling and batching. * Higher values indicate higher priority. * * Priority assignment rules: * - IMMEDIATE (100): Actions with __immediate flag, always execute immediately * - ROOT_THUNK_ACTION (70): Actions belonging to the active root thunk * - NORMAL_THUNK_ACTION (50): Regular thunk-dispatched actions (renderer default) * - NORMAL_ACTION (0): Regular actions without special flags (main process default) * * Note: ActionBatcher (renderer) uses NORMAL_THUNK_ACTION as default, * while ActionScheduler (main) uses NORMAL_ACTION. This distinction * allows different priority handling in renderer vs main process. */ const PRIORITY_LEVELS = { IMMEDIATE: 100, ROOT_THUNK_ACTION: 70, NORMAL_THUNK_ACTION: 50, NORMAL_ACTION: 0 }; //#endregion //#region src/batching/ActionBatcher.ts const uuidv4$1 = () => { return self.crypto.randomUUID(); }; var ActionBatcher = class { /** * Hard limit for queue size to prevent DoS attacks. * Set to 4x maxBatchSize to allow some buffering while preventing unbounded growth. * If queue exceeds this limit, new actions are rejected immediately. */ HARD_QUEUE_LIMIT; queue = []; activeBatch = []; flushTimeoutId = null; isFlushing = false; flushingPromise = null; flushResultWaiters = /* @__PURE__ */ new Set(); isDestroyed = false; pendingForceFlush = false; stats = { totalBatches: 0, totalActions: 0, rejectedActions: 0 }; constructor(config, sendBatch) { this.config = config; this.sendBatch = sendBatch; this.HARD_QUEUE_LIMIT = Math.max(this.config.maxBatchSize * 4, 100); debugLog("batching", `ActionBatcher initialized with hard queue limit: ${this.HARD_QUEUE_LIMIT}`); } enqueue(action, resolve, reject, priority, parentId) { if (this.isDestroyed) { reject(/* @__PURE__ */ new Error("ActionBatcher is destroyed")); return ""; } const id = action.__id || uuidv4$1(); if (this.queue.length >= this.HARD_QUEUE_LIMIT) { const error = /* @__PURE__ */ new Error(`ActionBatcher queue exceeded hard limit (${this.HARD_QUEUE_LIMIT}). This may indicate a memory leak or DoS attack. Current queue size: ${this.queue.length}`); this.stats.rejectedActions++; debugLog("batching:error", `Action ${action.type} rejected: queue at hard limit (${this.queue.length}/${this.HARD_QUEUE_LIMIT})`); reject(error); return id; } if (this.shouldFlushNow(priority)) { debugLog("batching", `Immediate flush triggered for high-priority action ${action.type}`); this.queue.unshift({ action, resolve, reject, priority, id, parentId }); if (this.isFlushing) this.pendingForceFlush = true; else this.flush(true); return id; } if (this.queue.length >= this.config.maxBatchSize) { debugLog("batching", `Queue full, flushing before adding action ${action.type}`); if (this.isFlushing) this.pendingForceFlush = true; else this.flush(true); } this.addToQueue(action, resolve, reject, priority, id, parentId); if (!this.flushTimeoutId && !this.isFlushing) this.scheduleFlush(); return id; } shouldFlushNow(priority) { return priority >= this.config.priorityFlushThreshold; } scheduleFlush() { if (this.flushTimeoutId) return; this.flushTimeoutId = setTimeout(() => { this.flushTimeoutId = null; if (this.queue.length > 0) this.flush(); }, this.config.windowMs); } async flush(force = false) { if (this.isFlushing) { debugLog("batching", "Flush already in progress, deferring"); if (force) this.pendingForceFlush = true; return; } if (this.queue.length === 0) { debugLog("batching", "Queue empty, nothing to flush"); return; } if (this.flushTimeoutId) { clearTimeout(this.flushTimeoutId); this.flushTimeoutId = null; } this.isFlushing = true; const doFlush = async () => { let batchId = ""; try { this.activeBatch = this.prepareBatch(); batchId = uuidv4$1(); const actionIds = this.activeBatch.map((item) => item.id); debugLog("batching", `Flushing batch ${batchId} with ${this.activeBatch.length} actions`); const payload = { batchId, actions: this.activeBatch.map((item) => ({ action: item.action, id: item.id, parentId: item.parentId })) }; this.stats.totalBatches++; this.stats.totalActions += this.activeBatch.length; const ackPayload = await this.sendBatch(payload); if (!this.isDestroyed) { const resultMap = /* @__PURE__ */ new Map(); if (ackPayload?.results) for (const result$1 of ackPayload.results) resultMap.set(result$1.actionId, result$1); for (const item of this.activeBatch) { const result$1 = resultMap.get(item.id); if (!result$1) item.reject(/* @__PURE__ */ new Error(`No result received for action ${item.id}`)); else if (!result$1.success) item.reject(new Error(result$1.error || `Action ${item.id} failed`)); else item.resolve(item.action); } } const result = { batchId, actionsSent: actionIds.length, actionIds }; for (const resolve of this.flushResultWaiters) resolve(result); this.flushResultWaiters.clear(); } catch (error) { debugLog("batching:error", `Batch ${batchId || "<unknown>"} failed:`, error); if (!this.isDestroyed) this.activeBatch.forEach((item) => { item.reject(error); }); const errorResult = { batchId, actionsSent: 0, actionIds: [] }; for (const resolve of this.flushResultWaiters) resolve(errorResult); this.flushResultWaiters.clear(); } finally { this.activeBatch = []; this.isFlushing = false; this.flushingPromise = null; if (this.flushResultWaiters.size > 0) { const fallback = { batchId: "", actionsSent: 0, actionIds: [] }; for (const resolve of this.flushResultWaiters) resolve(fallback); this.flushResultWaiters.clear(); } if (!this.isDestroyed) { if (this.pendingForceFlush) { this.pendingForceFlush = false; if (this.queue.length > 0) this.flush(true); } else if (this.queue.length > 0) this.scheduleFlush(); } } }; this.flushingPromise = doFlush(); await this.flushingPromise; } /** * Flush pending actions and return result with batch stats. * This is used for manual flush from thunks. * * Results are delivered exclusively via flushResultWaiters — doFlush resolves * all registered waiters, so there is no stale result window between * scheduled flushes. */ async flushWithResult(force = false) { const emptyResult = { batchId: "", actionsSent: 0, actionIds: [] }; if (this.isDestroyed) return emptyResult; if (this.isFlushing) return new Promise((resolve) => { this.flushResultWaiters.add(resolve); }); if (this.queue.length === 0) return emptyResult; const resultPromise = new Promise((resolve) => { this.flushResultWaiters.add(resolve); }); await this.flush(force); return resultPromise; } prepareBatch() { return this.queue.splice(0, this.config.maxBatchSize); } addToQueue(action, resolve, reject, priority, id, parentId) { this.queue.push({ action, resolve, reject, priority, id, parentId }); debugLog("batching", `Added action ${action.type} to queue (size: ${this.queue.length})`); } removeAction(actionId) { const index = this.queue.findIndex((item) => item.id === actionId); if (index !== -1) { this.queue.splice(index, 1)[0].reject(/* @__PURE__ */ new Error(`Action ${actionId} was cancelled`)); debugLog("batching", `Removed action ${actionId} from queue`); return true; } return false; } getStats() { return { totalBatches: this.stats.totalBatches, totalActions: this.stats.totalActions, averageBatchSize: this.stats.totalBatches > 0 ? this.stats.totalActions / this.stats.totalBatches : 0, currentQueueSize: this.queue.length, isFlushing: this.isFlushing, rejectedActions: this.stats.rejectedActions, queueLimit: this.HARD_QUEUE_LIMIT }; } destroy() { this.isDestroyed = true; if (this.flushTimeoutId) { clearTimeout(this.flushTimeoutId); this.flushTimeoutId = null; } this.pendingForceFlush = false; this.queue.forEach((item) => { item.reject(/* @__PURE__ */ new Error("ActionBatcher destroyed")); }); this.queue = []; this.activeBatch.forEach((item) => { item.reject(/* @__PURE__ */ new Error("ActionBatcher destroyed")); }); this.activeBatch = []; const emptyResult = { batchId: "", actionsSent: 0, actionIds: [] }; for (const resolve of this.flushResultWaiters) resolve(emptyResult); this.flushResultWaiters.clear(); debugLog("batching", "ActionBatcher destroyed"); } }; /** * Calculate the priority for an action based on its flags. * Uses centralized PRIORITY_LEVELS constants for consistency across the system. * * Priority rules: * - Actions with __immediate get IMMEDIATE priority (100) * - Actions with __thunkParentId get ROOT_THUNK_ACTION priority (70) * - All other actions get NORMAL_THUNK_ACTION priority (50) * * Note: This function is used in the renderer process (ActionBatcher). * The renderer does not have access to the active root thunk context; * without __thunkParentId on the action, all renderer thunk actions * default to NORMAL_THUNK_ACTION priority (50). * The main process (ActionScheduler) distinguishes between root and * nested thunks, assigning NORMAL_THUNK_ACTION (50) to non-root thunks. */ function calculatePriority(action) { if (action.__immediate) return PRIORITY_LEVELS.IMMEDIATE; if (action.__thunkParentId) return PRIORITY_LEVELS.ROOT_THUNK_ACTION; return PRIORITY_LEVELS.NORMAL_THUNK_ACTION; } //#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 }; } /** * Formats Zod validation errors into human-readable messages * @param error - Zod validation error * @returns Formatted error message */ function formatZodError(error) { if (error.issues.length === 0) return "Invalid payload structure"; const uniqueIssues = [...new Set(error.issues.map((i) => i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message))]; const summary = uniqueIssues.slice(0, 3).join("; "); return uniqueIssues.length > 3 ? `${summary} (+${uniqueIssues.length - 3} more)` : summary; } /** * Get the renderer validation level from environment * Defaults to 'warn' in development, 'off' in production */ function getRendererValidationLevel() { const envLevel = process.env.ZUBRIDGE_RENDERER_VALIDATION; if (envLevel === "off" || envLevel === "warn" || envLevel === "error") return envLevel; return process.env.NODE_ENV === "development" ? "warn" : "off"; } /** * Validates an action in the renderer process * Used by preload script to validate actions before sending to main * * @param action - The action to validate * @param parentId - Optional parent thunk ID * @param level - Validation level (defaults to environment-based) */ function validateActionInRenderer(action, parentId, level = getRendererValidationLevel()) { if (level === "off") return; const result = validateSingleDispatch({ action, parentId }); if (!result.success) { const message = `[Zubridge] Invalid action dispatch: ${result.error}`; const details = { action, parentId, error: result.error, validationDetails: result.details }; if (level === "error") { console.error(message, details); throw new Error(message); } console.warn(message, details); } } //#endregion //#region src/constants.ts /** * Constants used for IPC communication between main and renderer processes. * These are internal to the Zubridge electron implementation. */ let IpcChannel = /* @__PURE__ */ function(IpcChannel$1) { /** * Used by renderer to send actions to main process */ IpcChannel$1["DISPATCH"] = "zubridge:dispatch"; /** * Used by main process to acknowledge action receipt */ IpcChannel$1["DISPATCH_ACK"] = "zubridge:dispatch-ack"; /** * Used by renderer to get state from main process */ IpcChannel$1["GET_STATE"] = "zubridge:get-state"; /** * Used by main process to send state updates to renderer */ IpcChannel$1["SUBSCRIBE"] = "zubridge:subscribe"; /** * Used by main process to send state updates to renderer with tracking */ IpcChannel$1["STATE_UPDATE"] = "zubridge:state-update"; /** * Used by renderer to acknowledge receipt of state update */ IpcChannel$1["STATE_UPDATE_ACK"] = "zubridge:state-update-ack"; /** * Used by renderer to register a thunk with main process */ IpcChannel$1["REGISTER_THUNK"] = "zubridge:register-thunk"; /** * Used by main process to acknowledge thunk registration */ IpcChannel$1["REGISTER_THUNK_ACK"] = "zubridge:register-thunk-ack"; /** * Used by renderer to notify main process of thunk completion */ IpcChannel$1["COMPLETE_THUNK"] = "zubridge:complete-thunk"; /** * Used by renderer to get window ID from main process */ IpcChannel$1["GET_WINDOW_ID"] = "zubridge:get-window-id"; /** * Used by renderer to get current thunk state from main process */ IpcChannel$1["GET_THUNK_STATE"] = "zubridge:get-thunk-state"; /** * Used by renderer to track action dispatch for performance metrics */ IpcChannel$1["TRACK_ACTION_DISPATCH"] = "zubridge:track-action-dispatch"; /** * Used by renderer to get window subscriptions from main process */ IpcChannel$1["GET_WINDOW_SUBSCRIPTIONS"] = "zubridge:get-window-subscriptions"; /** * Used by renderer to send batched actions to main process */ IpcChannel$1["BATCH_DISPATCH"] = "zubridge:batch-dispatch"; /** * Used by main process to acknowledge batch dispatch */ IpcChannel$1["BATCH_ACK"] = "zubridge:batch-ack"; return IpcChannel$1; }({}); //#endregion //#region src/deltas/DeltaMerger.ts var DeltaMerger = class { merge(currentState, delta) { const hasChanges = delta.changed && Object.keys(delta.changed).length > 0; const hasRemovals = delta.removed && delta.removed.length > 0; if (delta.type === "full" || !hasChanges && !hasRemovals) { if (delta.fullState && Object.keys(delta.fullState).length > 0) return structuredClone(delta.fullState); return { ...currentState }; } const result = this.cloneWithStructuralSharing(currentState); if (delta.changed) for (const [keyPath, value] of Object.entries(delta.changed).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) this.setDeepWithStructuralSharing(result, currentState, keyPath, value); if (delta.removed) for (const keyPath of delta.removed) this.deleteDeep(result, currentState, keyPath); return result; } cloneWithStructuralSharing(obj) { return { ...obj }; } setDeepWithStructuralSharing(result, original, path, value) { const keys = path.split("."); if (keys.length === 1) { result[keys[0]] = this.cloneValue(value); return; } const pathToParent = keys.slice(0, -1); const finalKey = keys[keys.length - 1]; let current = result; let originalCurrent = original; for (let i = 0; i < pathToParent.length; i++) { const key = pathToParent[i]; const existingInResult = current[key]; const originalValue = originalCurrent[key]; if (existingInResult && typeof existingInResult === "object" && existingInResult !== originalValue) { current = existingInResult; originalCurrent = originalValue ?? existingInResult; } else if (originalValue !== null && typeof originalValue === "object") { const cloned = Array.isArray(originalValue) ? [...originalValue] : { ...originalValue }; current[key] = cloned; current = cloned; originalCurrent = originalValue; } else { const newObj = {}; current[key] = newObj; current = newObj; originalCurrent = newObj; } } current[finalKey] = this.cloneValue(value); } cloneValue(value) { if (value === null || value === void 0 || typeof value !== "object") return value; return structuredClone(value); } deleteDeep(obj, original, path) { const keys = path.split("."); if (keys.length === 1) { delete obj[keys[0]]; return; } let current = obj; let originalCurrent = original; for (let i = 0; i < keys.length - 1; i++) { const next = current[keys[i]]; if (next == null || typeof next !== "object") return; const originalValue = originalCurrent[keys[i]]; if (next !== originalValue) { current = next; originalCurrent = originalValue ?? next; } else { const cloned = Array.isArray(next) ? [...next] : { ...next }; current[keys[i]] = cloned; current = cloned; originalCurrent = originalValue ?? next; } } delete current[keys[keys.length - 1]]; } }; //#endregion //#region src/renderer/preloadListeners.ts var CleanupRegistry = class { cleanups = []; add(cleanup) { this.cleanups.push(cleanup); } remove(cleanup) { const index = this.cleanups.indexOf(cleanup); if (index !== -1) this.cleanups.splice(index, 1); } async cleanupAll() { (await Promise.allSettled(this.cleanups.map((cleanup) => cleanup()))).forEach((result, index) => { if (result.status === "rejected") debugLog("cleanup:error", `Cleanup ${index} failed:`, result.reason); }); this.cleanups.length = 0; } }; function createIPCManager({ ipcRenderer: ipcRenderer$1 }) { const ipcListeners = /* @__PURE__ */ new Map(); const ipcCleanupFunctions = /* @__PURE__ */ new Map(); const cleanupRegistry = { ipc: new CleanupRegistry(), dom: new CleanupRegistry(), async cleanupAll() { await Promise.all([this.ipc.cleanupAll(), this.dom.cleanupAll()]); } }; const registerIpcListener = (channel, listener) => { try { const existingListener = ipcListeners.get(channel); const existingCleanup = ipcCleanupFunctions.get(channel); if (existingCleanup) { cleanupRegistry.ipc.remove(existingCleanup); ipcCleanupFunctions.delete(channel); } ipcRenderer$1.on(channel, listener); if (existingListener) ipcRenderer$1.removeListener(channel, existingListener); ipcListeners.set(channel, listener); const cleanupFn = () => { ipcRenderer$1.removeListener(channel, listener); ipcListeners.delete(channel); ipcCleanupFunctions.delete(channel); }; ipcCleanupFunctions.set(channel, cleanupFn); cleanupRegistry.ipc.add(cleanupFn); } catch (error) { debugLog("ipc:error", `Failed to register IPC listener for channel ${channel}:`, error); } }; return { ipcListeners: { get: (channel) => ipcListeners.get(channel), delete: (channel) => { const listener = ipcListeners.get(channel); if (listener) ipcRenderer$1.removeListener(channel, listener); const cleanup = ipcCleanupFunctions.get(channel); if (cleanup) { cleanupRegistry.ipc.remove(cleanup); ipcCleanupFunctions.delete(channel); } ipcListeners.delete(channel); } }, cleanupRegistry, registerIpcListener }; } //#endregion //#region ../../node_modules/.pnpm/unenv@2.0.0-rc.24/node_modules/unenv/dist/runtime/node/internal/crypto/web.mjs const subtle = globalThis.crypto?.subtle; const randomUUID = () => { return globalThis.crypto?.randomUUID(); }; //#endregion //#region src/types/errors.ts /** * Error thrown when the action queue exceeds its maximum size * * This error indicates that the application is generating actions faster than * they can be processed, which could lead to memory issues or performance degradation. * * @example * ```typescript * import { QueueOverflowError } from '@zubridge/electron'; * * try { * await dispatch(someAction); * } catch (error) { * if (error instanceof QueueOverflowError) { * debug('queue:error', 'Action queue is full:', error.message); * // Handle overflow - maybe wait and retry, or warn user * } * } * ``` */ var QueueOverflowError = class extends Error { queueSize; maxSize; constructor(queueSize, maxSize) { super(`Action queue overflow: ${queueSize} actions pending, maximum allowed is ${maxSize}`); this.name = "QueueOverflowError"; this.queueSize = queueSize; this.maxSize = maxSize; } }; //#endregion //#region src/thunk/shared/BaseThunkProcessor.ts /** * Base class for thunk processors that handles common functionality * like action completion tracking, timeouts, and queue management */ var BaseThunkProcessor = class { actionCompletionTimeoutMs; maxQueueSize; actionCompletionCallbacks = /* @__PURE__ */ new Map(); actionTimeouts = /* @__PURE__ */ new Map(); constructor(options, logPrefix) { this.logPrefix = logPrefix; this.actionCompletionTimeoutMs = options.actionCompletionTimeoutMs; this.maxQueueSize = options.maxQueueSize; debugLog("core", `[${this.logPrefix}] Initialized with timeout: ${this.actionCompletionTimeoutMs}ms, maxQueueSize: ${this.maxQueueSize}`); } /** * Generate a unique action ID if one doesn't exist */ ensureActionId(action, payload) { const actionObj = typeof action === "string" ? { type: action, payload, __id: randomUUID() } : { ...action, __id: action.__id || randomUUID() }; if (!actionObj.__id) actionObj.__id = randomUUID(); return actionObj; } /** * Check if queue has capacity and throw QueueOverflowError if not */ checkQueueCapacity(currentSize) { if (currentSize >= this.maxQueueSize) { const error = new QueueOverflowError(currentSize, this.maxQueueSize); debugLog("core:error", `[${this.logPrefix}] Queue overflow: ${error.message}`); throw error; } } /** * Set up action completion tracking with timeout */ setupActionCompletion(actionId, callback, timeoutCallback) { this.actionCompletionCallbacks.set(actionId, callback); debugLog("core", `[${this.logPrefix}] Set completion callback for action ${actionId}`); const safetyTimeout = setTimeout(() => { if (this.actionCompletionCallbacks.has(actionId)) { debugLog("core", `[${this.logPrefix}] Safety timeout triggered for action ${actionId} after ${this.actionCompletionTimeoutMs}ms`); if (timeoutCallback) timeoutCallback(); else this.completeActionInternal(actionId, { __timeout: true }); } }, this.actionCompletionTimeoutMs); this.actionTimeouts.set(actionId, safetyTimeout); } /** * Complete an action and call its callback */ completeActionInternal(actionId, result) { const callback = this.actionCompletionCallbacks.get(actionId); if (!callback) { debugLog("core", `[${this.logPrefix}] No completion callback found for action ${actionId}`); return false; } const timeout = this.actionTimeouts.get(actionId); if (timeout) { clearTimeout(timeout); this.actionTimeouts.delete(actionId); } try { debugLog("core", `[${this.logPrefix}] Action ${actionId} result: ${JSON.stringify(result)}`); } catch { debugLog("core", `[${this.logPrefix}] Action ${actionId} result: [Non-serializable result]`); } try { callback(result); debugLog("core", `[${this.logPrefix}] Completion callback executed for action ${actionId}`); } catch (callbackError) { debugLog("core:error", `[${this.logPrefix}] Error in completion callback for action ${actionId}: ${callbackError}`); } this.actionCompletionCallbacks.delete(actionId); return true; } /** * Handle action completion with error checking */ completeAction(actionId, result) { debugLog("core", `[${this.logPrefix}] Action completed: ${actionId}`); const { error: errorString } = result; if (errorString) debugLog("core:error", `[${this.logPrefix}] Action ${actionId} completed with error: ${errorString}`); this.completeActionInternal(actionId, result); } /** * Force cleanup of expired actions and timeouts * This prevents memory leaks from stale actions */ forceCleanupExpiredActions() { debugLog("core", `[${this.logPrefix}] Force cleaning up expired actions and timeouts`); for (const [actionId, timeout] of this.actionTimeouts) { debugLog("core", `[${this.logPrefix}] Force clearing timeout for action ${actionId}`); clearTimeout(timeout); } const clearedTimeouts = this.actionTimeouts.size; const clearedCallbacks = this.actionCompletionCallbacks.size; this.actionTimeouts.clear(); this.actionCompletionCallbacks.clear(); debugLog("core", `[${this.logPrefix}] Force cleaned up ${clearedTimeouts} timeouts, ${clearedCallbacks} callbacks`); } /** * Test method to check queue capacity (for testing purposes) */ testCheckQueueCapacity(currentSize) { this.checkQueueCapacity(currentSize); } /** * Destroy and cleanup all resources */ destroy() { debugLog("core", `[${this.logPrefix}] Destroying processor instance`); this.forceCleanupExpiredActions(); debugLog("core", `[${this.logPrefix}] Processor instance destroyed`); } }; //#endregion //#region src/thunk/Thunk.ts let ThunkState = /* @__PURE__ */ function(ThunkState$1) { ThunkState$1["PENDING"] = "pending"; ThunkState$1["EXECUTING"] = "executing"; ThunkState$1["COMPLETED"] = "completed"; ThunkState$1["FAILED"] = "failed"; return ThunkState$1; }({}); /** * Base Thunk class that works in both main and renderer processes */ var Thunk = class { /** Unique identifier for this thunk */ id; /** ID of the window that dispatched this thunk */ _sourceWindowId; /** Parent thunk ID if this is a nested thunk */ parentId; /** Thunk source - the process that dispatched this thunk */ source; /** Current state of the thunk */ _state; /** Time when the thunk was created */ startTime; /** Set of child thunk IDs */ children; /** Keys this thunk will affect (for key-based locking) */ keys; /** Flag for immediate execution, bypassing all queues */ immediate; /** Flag for access control bypass */ bypassAccessControl; /** ID of the linked execution context */ _contextId; constructor(options) { this.id = options.id || randomUUID(); this._sourceWindowId = options.sourceWindowId; this.parentId = options.parentId; this.source = options.source; this._state = ThunkState.PENDING; this.startTime = Date.now(); this.children = /* @__PURE__ */ new Set(); this.keys = options.keys; this.immediate = options.immediate; this.bypassAccessControl = options.bypassAccessControl; this._contextId = options.contextId; debugLog("thunk", `Created thunk ${this.id} (type: ${this.source}, immediate: ${this.immediate})`); } /** * Get the source window ID */ get sourceWindowId() { return this._sourceWindowId; } /** * Set the source window ID */ set sourceWindowId(windowId) { this._sourceWindowId = windowId; } /** * Get the current state of the thunk */ get state() { return this._state; } /** * Get the execution context ID */ get contextId() { return this._contextId; } /** * Set the execution context ID */ set contextId(contextId) { this._contextId = contextId; } /** * Mark the thunk as active (processing) */ activate() { this._state = ThunkState.EXECUTING; } /** * Mark the thunk as completed */ complete() { this._state = ThunkState.COMPLETED; } /** * Mark the thunk as failed */ fail() { this._state = ThunkState.FAILED; } /** * Add a child thunk to this thunk */ addChild(childId) { this.children.add(childId); } /** * Get all child thunk IDs */ getChildren() { return Array.from(this.children); } /** * Check if the thunk is in a terminal state (completed or failed) */ isComplete() { return this.state === ThunkState.COMPLETED || this.state === ThunkState.FAILED; } }; //#endregion //#region src/utils/configuration.ts /** * Base default configuration values for thunk processors */ const THUNK_PROCESSOR_DEFAULTS$1 = { maxQueueSize: 100, actionCompletionTimeoutMs: process.platform === "linux" ? 6e4 : 3e4 }; /** * Merge user options with thunk processor defaults */ function getThunkProcessorOptions$1(userOptions) { return { maxQueueSize: userOptions?.maxQueueSize ?? THUNK_PROCESSOR_DEFAULTS$1.maxQueueSize, actionCompletionTimeoutMs: userOptions?.actionCompletionTimeoutMs ?? THUNK_PROCESSOR_DEFAULTS$1.actionCompletionTimeoutMs }; } //#endregion //#region src/renderer/rendererThunkProcessor.ts /** * Handles thunk execution in the renderer process */ var RendererThunkProcessor = class extends BaseThunkProcessor { currentWindowId; actionSender; batchFlusher; thunkRegistrar; thunkCompleter; stateProvider; pendingDispatches = /* @__PURE__ */ new Set(); constructor(options) { const config = getThunkProcessorOptions$1(options); super(config, "RENDERER_THUNK"); } /** * Initialize the processor with all required dependencies */ initialize(options) { debugLog("ipc", "[RENDERER_THUNK] Initializing with options:", options); this.currentWindowId = options.windowId; this.actionSender = options.actionSender; this.batchFlusher = options.batchFlusher; this.thunkRegistrar = options.thunkRegistrar; this.thunkCompleter = options.thunkCompleter; if (options.actionCompletionTimeoutMs !== void 0) { this.actionCompletionTimeoutMs = options.actionCompletionTimeoutMs; debugLog("ipc", "[RENDERER_THUNK] Updated timeout:", this.actionCompletionTimeoutMs); } debugLog("ipc", "[RENDERER_THUNK] Action sender:", this.actionSender); debugLog("ipc", `[RENDERER_THUNK] Initialized with window ID ${options.windowId}`); } /** * Set a custom state provider function * This allows explicitly registering a way to get state after initialization */ setStateProvider(provider) { this.stateProvider = provider; debugLog("ipc", "[RENDERER_THUNK] Custom state provider registered"); } /** * Handle action completion notification * This should be called when an action acknowledgment is received from the main process */ completeAction(actionId, result) { debugLog("ipc", `[RENDERER_THUNK] Action completed: ${actionId}`); super.completeAction(actionId, result); this.pendingDispatches.delete(actionId); debugLog("ipc", `[RENDERER_THUNK] Removed ${actionId} from pending dispatches, remaining: ${this.pendingDispatches.size}`); if (this.pendingDispatches.size > 0) debugLog("ipc", `[RENDERER_THUNK] Remaining dispatch IDs: ${Array.from(this.pendingDispatches).join(", ")}`); } /** * Execute a thunk function */ async executeThunk(thunkFn, options, parentId) { const thunk = new Thunk({ sourceWindowId: this.currentWindowId ?? 0, source: "renderer", parentId, bypassAccessControl: options?.bypassAccessControl ?? false, immediate: options?.immediate ?? false }); debugLog("ipc", `[RENDERER_THUNK] Executing thunk ${thunk.id} (immediate=${thunk.immediate})`); let isFirstAction = true; debugLog("ipc", `[RENDERER_THUNK] Thunk ${thunk.id} immediate: ${thunk.immediate}`); if (this.thunkRegistrar && this.currentWindowId) try { debugLog("ipc", `[RENDERER_THUNK] Registering thunk ${thunk.id} with main process (immediate=${thunk.immediate})`); await this.thunkRegistrar(thunk.id, parentId, options?.immediate, options?.bypassAccessControl); debugLog("ipc", `[RENDERER_THUNK] Thunk ${thunk.id} registered successfully`); } catch (error) { debugLog("ipc:error", `[RENDERER_THUNK] Error registering thunk: ${error}`); } try { const getState = async (getStateOptions) => { debugLog("ipc", `[RENDERER_THUNK] getState called for thunk ${thunk.id}`); if (this.stateProvider) { debugLog("ipc", `[RENDERER_THUNK] Using registered state provider for thunk ${thunk.id}`); const bypassFlag = getStateOptions?.bypassAccessControl ?? thunk.bypassAccessControl; return await this.stateProvider({ bypassAccessControl: bypassFlag }); } throw new Error("No state provider available"); }; const baseDispatch = async (action, payloadOrOptions, optionsIfString) => { let payload; let dispatchOptions; if (typeof action === "string") { payload = payloadOrOptions; dispatchOptions = optionsIfString; } else dispatchOptions = payloadOrOptions; const isBatched = dispatchOptions?.batch === true; debugLog("ipc", `[RENDERER_THUNK] [${thunk.id}] Dispatch called (immediate=${thunk.immediate}, batch=${isBatched})`, action); if (typeof action === "function") { debugLog("ipc", `[RENDERER_THUNK] Handling nested thunk in ${thunk.id}`); return this.executeThunk(action, dispatchOptions, thunk.id); } const actionObj = this.ensureActionId(action, payload); if (dispatchOptions?.immediate ?? thunk.immediate) actionObj.__immediate = true; if (dispatchOptions?.bypassAccessControl ?? thunk.bypassAccessControl) actionObj.__bypassAccessControl = true; const actionId = actionObj.__id; debugLog("ipc", `[RENDERER_THUNK] Thunk ${thunk.id} dispatching action ${actionObj.type} (${actionId})`); if (isFirstAction) { debugLog("ipc", `[RENDERER_THUNK] Marking action ${actionId} as starting thunk ${thunk.id}`); actionObj.__startsThunk = true; isFirstAction = false; } this.checkQueueCapacity(this.pendingDispatches.size); this.pendingDispatches.add(actionId); debugLog("ipc", `[RENDERER_THUNK] Added ${actionId} to pending dispatches, now pending: ${this.pendingDispatches.size}/${this.maxQueueSize}`); const actionPromise = new Promise((resolve, reject) => { this.setupActionCompletion(actionId, (result) => { const { error: errorString } = result; debugLog("ipc", `[RENDERER_THUNK] Action ${actionId} completion callback called with result`, result); if (errorString) { debugLog("ipc:error", `[RENDERER_THUNK] Rejecting promise for action ${actionId} with error: ${errorString}`); reject(new Error(errorString)); } else resolve(result || actionObj); }, () => { debugLog("ipc", `[RENDERER_THUNK] Safety timeout triggered for action ${actionId}`); this.completeAction(actionId, actionObj); }); }); if (this.actionSender) try { debugLog("ipc", `[RENDERER_THUNK] Sending action ${actionId} to main process`); await this.actionSender(actionObj, thunk.id, { batch: isBatched }); debugLog("ipc", `[RENDERER_THUNK] Action ${actionId} sent to main process`); } catch (error) { this.completeAction(actionId, { error: String(error) }); this.pendingDispatches.delete(actionId); debugLog("ipc:error", `[RENDERER_THUNK] Error sending action ${actionId}:`, error); throw error; } else { debugLog("ipc:error", `[RENDERER_THUNK] No action sender configured, cannot send action ${actionId}`); throw new Error("Action sender not configured for renderer thunk processor"); } return actionPromise; }; const dispatch = Object.assign(baseDispatch, { batch: async (action, payloadOrOpts, optsIfString) => { if (typeof action === "string") await baseDispatch(action, payloadOrOpts, { ...optsIfString, batch: true }); else await baseDispatch(action, { ...payloadOrOpts ?? {}, batch: true }); }, flush: async () => { debugLog("ipc", `[RENDERER_THUNK] [${thunk.id}] Flushing pending batched actions`); if (!this.batchFlusher) { debugLog("ipc", "[RENDERER_THUNK] No batch flusher available"); return { batchId: "", actionsSent: 0, actionIds: [] }; } return this.batchFlusher(); } }); debugLog("ipc", `[RENDERER_THUNK] Executing thunk function for ${thunk.id}`); try { const result = await thunkFn(getState, dispatch); debugLog("ipc", `[RENDERER_THUNK] Thunk ${thunk.id} execution completed, result:`, result); return result; } catch (thunkError) { debugLog("ipc:error", `[RENDERER_THUNK] Thunk ${thunk.id} threw error:`, thunkError); throw thunkError; } } catch (error) { debugLog("ipc:error", `[RENDERER_THUNK] Error executing thunk ${thunk.id}:`, error); throw error; } finally { if (this.thunkCompleter && this.currentWindowId) try { debugLog("ipc", `[RENDERER_THUNK] Notifying main process of thunk ${thunk.id} completion`); await this.thunkCompleter(thunk.id); debugLog("ipc", `[RENDERER_THUNK] Thunk ${thunk.id} completion notified`); } catch (e) { debugLog("ipc:error", `[RENDERER_THUNK] Error notifying thunk completion: ${e}`); } } } /** * Dispatch an action to the main process (for non-thunk scenarios) */ async dispatchAction(action, payload, parentId) { debugLog("ipc", "[RENDERER_THUNK] dispatchAction called with:", { action, payload, parentId }); if (typeof window !== "undefined" && window.zubridge?.dispatch) { debugLog("ipc", "[RENDERER_THUNK] Using window.zubridge.dispatch for action"); try { if (typeof action === "string") await window.zubridge.dispatch(action, payload); else await window.zubridge.dispatch(action); return; } catch (error) { debugLog("ipc:error", "[RENDERER_THUNK] Error dispatching through window.zubridge:", error); } } if (!this.actionSender) { debugLog("ipc:error", "[RENDERER_THUNK] dispatchAction: No action sender configured, cannot dispatch."); throw new Error("Action sender not configured for direct dispatch."); } const actionObj = this.ensureActionId(action, payload); const actionId = actionObj.__id; return new Promise((resolve, reject) => { try { this.checkQueueCapacity(this.pendingDispatches.size); } catch (error) { reject(error); return; } this.pendingDispatches.add(actionId); debugLog("ipc", `[RENDERER_THUNK] Added ${actionId} to pending dispatches, now pending: ${this.pendingDispatches.size}/${this.maxQueueSize}`); this.setupActionCompletion(actionId, (result) => { const { error: errorString } = result; debugLog("ipc", `[RENDERER_THUNK] Action ${actionId} completion callback called with result:`, result); if (errorString) { debugLog("ipc:error", `[RENDERER_THUNK] Rejecting promise for action ${actionId} with error: ${errorString}`); reject(new Error(errorString)); } else resolve(); }, () => { debugLog("ipc", `[RENDERER_THUNK] Safety timeout triggered for action ${actionId}`); this.completeAction(actionId, actionObj); }); debugLog("ipc", `[RENDERER_THUNK] dispatchAction: Sending action ${actionObj.type} (${actionObj.__id})`); this.actionSender?.(actionObj, parentId).then(() => { debugLog("ipc", `[RENDERER_THUNK] dispatchAction: Action ${actionObj.__id} sent.`); }).catch((error) => { this.completeAction(actionId, { error: error.message }); this.pendingDispatches.delete(actionId); debugLog("ipc:error", `[RENDERER_THUNK] Error sending action ${actionId}:`, error); reject(error); }); }); } /** * Force cleanup of expired timeouts and callbacks * This prevents memory leaks from stale actions */ forceCleanupExpiredActions() { debugLog("ipc", "[RENDERER_THUNK] Force cleaning up expired actions and timeouts"); super.forceCleanupExpiredActions(); const clearedDispatches = this.pendingDispatches.size; this.pendingDispatches.clear(); debugLog("ipc", `[RENDERER_THUNK] Force cleaned up ${clearedDispatches} pending dispatches`); } /** * Destroy the processor and cleanup all resources */ destroy() { debugLog("ipc", "[RENDERER_THUNK] Destroying RendererThunkProcessor instance"); this.forceCleanupExpiredActions(); this.actionSender = void 0; this.thunkRegistrar = void 0; this.thunkCompleter = void 0; this.stateProvider = void 0; this.currentWindowId = void 0; super.destroy(); } }; //#endregion //#region src/errors/index.ts /** * Custom Error classes for different areas of the Zubridge application * Provides better type safety, debugging, and error handling */ /** * Base class for all Zubridge errors */ var ZubridgeError = class extends Error { timestamp; context; constructor(message, context) { super(message); this.name = this.constructor.name; this.timestamp = Date.now(); this.context = context; if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor); } /** * Get error details for logging */ getDetails() { return { name: this.name, message: this.message, timestamp: this.timestamp, context: this.context, stack: this.stack }; } }; /** * Errors related to configuration and validation */ var ConfigurationError = class extends ZubridgeError { configPath; expectedType; actualType; constructor(message, context) { super(message, context); this.configPath = context?.configPath; this.expectedType = context?.expectedType; this.actualType = context?.actualType; } }; //#endregion //#region src/utils/errorHandling.ts /** * Enhanced error logging that preserves Error instances */ function logZubridgeError(error, additionalInfo) { debugLog(`${error.constructor.name.replace("Error", "").toLowerCase()}:error`, `${error.name}:`, { ...error.getDetails(), ...additionalInfo }); } //#endregion //#region src/utils/globalErrorHandlers.ts /** * Sets up error handlers for the renderer process */ function setupRendererErrorHandlers() { if (typeof window !== "undefined") { window.addEventListener("unhandledrejection", (event) => { logZubridgeError(new ConfigurationError("Unhandled promise rejection in renderer process", { reason: event.reason instanceof Error ? event.reason.message : String(event.reason), originalReason: event.reason })); debugLog("renderer:error", "Unhandled promise rejection detected and logged"); event.preventDefault(); }); window.addEventListener("error", (event) => { logZubridgeError(new ConfigurationError("Uncaught error in renderer process", { message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, originalError: event.error })); debugLog("renderer:error", "Uncaught error detected and logged"); }); debugLog("renderer", "Global error handlers setup for renderer process"); } } //#endregion //#region src/utils/thunkProcessor.ts /** * Base default configuration values for thunk processors */ const THUNK_PROCESSOR_DEFAULTS = { maxQueueSize: 100, actionCompletionTimeoutMs: process.platform === "linux" ? 6e4 : 3e4 }; /** * Merge user options with thunk processor defaults */ function getThunkProcessorOptions(userOptions) { return { maxQueueSize: userOptions?.maxQueueSize ?? THUNK_PROCESSOR_DEFAULTS.maxQueueSize, actionCompletionTimeoutMs: userOptions?.actionCompletionTimeoutMs ?? THUNK_PROCESSOR_DEFAULTS.actionCompletionTimeoutMs }; } //#endregion //#region src/utils/preloadOptions.ts /** * Get batching configuration with defaults */ function getBatchingConfig(userConfig) { return { windowMs: userConfig?.windowMs ?? BATCHING_DEFAULTS.windowMs, maxBatchSize: userConfig?.maxBatchSize ?? BATCHING_DEFAULTS.maxBatchSize, priorityFlushThreshold: userConfig?.priorityFlushThreshold ?? BATCHING_DEFAULTS.priorityFlushThreshold, ackTimeoutMs: userCo