The AFFiNE Electron module is structured to provide a robust, maintainable, and type-safe environment for desktop application functionality.
It leverages NestJS for organizing backend logic in both the main and helper processes, a custom-built IPC system for communication, and a dedicated helper process for offloading intensive tasks.
Both the Electron main process and the separate helper process are built as NestJS applications. This provides several benefits:
Modularity: Code is organized into modules (e.g., AppModule
, IpcModule
), services, and controllers (though traditional controllers aren't typical in Electron main process logic, the concept of service-oriented architecture is key).
Most side effects should be placed within onModuleInit
lifecycle hook, except the ones that are restrictly to be called before app ready.
Services will contain most of the business logic and bindings to Electron related APIs.
Methods and events in servies also can be decorated with IPCHandle
and IPCEvent
to expose native handlers & events for the renderer processes
Dependency Injection: NestJS's DI system simplifies managing dependencies between different parts of the application, making services and configurations easy to inject and use.
Scalability and Maintainability: The structured nature of NestJS makes the codebase easier to understand, scale, and maintain, especially as the application grows in complexity.
Standard Entry Points: Each process (main, helper) has a bootstrap.ts
file to initialize the NestJS application and an app.module.ts
as the root module. Since we do not need the network layer of NestJS, we will bootstrap the processes with NestFactory.createApplicationContext(AppModule)
.
AFFiNE employs a custom IPC (Inter-Process Communication) system to facilitate communication between the main process, renderer process (UI), and the helper process. There are four parties in the IPC: main, helper, renderer(s), shared-worker.
Main/Helper process services can expose methods to the renderer process (browser windows or tabs) using the @IpcHandle({ scope: IPCScope.xxx, name?: 'methodName' })
decorator to decorate instance methods of any @Injectable
.
These decorated methods become asynchronous APIs that the renderer can invoke.
An IpcScanner
service discovers these handlers at startup.
The IpcMainInitializerService
then uses ipcMain.handle
to register these methods, making them available for invocation from the preload script.
Helper process uses a different approach to exchange data with renderers via MessagePort
s
decorated IPC handlers has access to IpcMainInvokeEvent
via AsyncLocalStorage
by calling getIpcEvent
.
IPC Events (broadcast from Main to Renderers):
Services in the main process can define event sources (typically RxJS Subjects
or Observables
) and decorate them with @IpcEvent({ scope: IPCScope.xxx, name?: 'eventName' })
.
Renderer process can access the events by using callbacks like __events.scope.onXXXChange
The IpcMainInitializerService
subscribes to these sources and broadcasts any emitted events to all renderer windows.
Type Checking and Generation:
A crucial feature is its build-time type generation for IPC calls, ensuring end-to-end type safety.
Scripts analyze @IpcHandle
and @IpcEvent
decorators (including method signatures, parameter types, and return types).
This process generates several TypeScript files by running yarn af electron generate-types
:
ipc-api-types.gen.ts
: Contains an ElectronApis
interface, strongly typing all invokable methods.
ipc-event-types.gen.ts
: Contains an ElectronEvents
interface, strongly typing event subscription and callback payloads.
ipc-meta.gen.ts
: Provides metadata about all registered handlers and events, used by the preload script.
Preload Script Bridge: The preload script utilizes this generated metadata and types to create a type-safe bridge using contextBridge.exposeInMainWorld('__apis', apiHandlers)
.
Renderer Process Usage: The renderer process can then access these APIs and events via __apis.scope.method()
and __events.scope.onEventName(...)
with full TypeScript autocompletion and type checking.
WebContentsView
In AFFiNE, each tab of the app lives in its own WebContentsView
thus the web content contexts are isolated. A shell web contents will be rendered as well to reduce tab flickering when opening a new tab.
The TabViewsManager
service manages the application's tab metadata (e.g., tab names, urls, active state, etc) and synced with the Workbench service in the renderer.
Each tab view will have their own initialization lifecycle, like loading preload script, listening to events from the main process. When calling native APIs, the handlers in the main process can get access to the caller's web contents via getIpcEvent
function call.
To prevent blocking the main process and keep the UI responsive, a dedicated helper process is used for computationally intensive tasks, .e.g., writing to native sqlite db (the nbstore modules).
Separate Node.js Context: The helper process is spawned using Electron's utilityProcess
API, running in an independent Node.js environment.
NestJS Application: It's also a lightweight NestJS application, configured using ElectronIpcModule.forHelper()
. This provides the IpcScanner
for its own internal handlers but omits main-process-specific services like IpcMainInitializerService
.
RPC-based Communication (Main to/from Helper):
Since the helper process does not have direct access to Electron APIs (like dialog
, shell
), and the main process might need to invoke helper-specific functions, a RPC channel is established using the AsyncCall-RPC
library.
Main to Helper: The main process can call functions exposed by the helper.
Helper to Main: The helper process can call Electron APIs that are explicitly exposed to it by the main process via RPC. For instance, the MainRpcService
in the helper process sets up an RPC server listening on process.parentPort
, allowing it to receive calls from the main process. Conversely, the HelperProcessService
in the main process manages the helper's lifecycle and facilitates RPC calls to it.
This bidirectional RPC ensures that tasks are delegated appropriately and that the helper can still interact with necessary Electron functionalities through a controlled interface.