@noxfly/noxus
Version:
Simulate lightweight HTTP-like requests between renderer and main process in Electron applications with MessagePort, with structured and modular design.
245 lines (208 loc) • 8.67 kB
text/typescript
/**
* @copyright 2025 NoxFly
* @license MIT
* @author NoxFly
*/
import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron/main";
import { Injectable } from "src/decorators/injectable.decorator";
import { IMiddleware } from "src/decorators/middleware.decorator";
import { inject } from "src/DI/app-injector";
import { InjectorExplorer } from "src/DI/injector-explorer";
import { IRequest, IResponse, Request } from "src/request";
import { NoxSocket } from "src/socket";
import { Router } from "src/router";
import { Logger } from "src/utils/logger";
import { Type } from "src/utils/types";
/**
* The application service should implement this interface, as
* the NoxApp class instance will use it to notify the given service
* about application lifecycle events.
*/
export interface IApp {
dispose(): Promise<void>;
onReady(mainWindow?: BrowserWindow): Promise<void>;
onActivated(): Promise<void>;
}
/**
* NoxApp is the main application class that manages the application lifecycle,
* handles IPC communication, and integrates with the Router.
*/
export class NoxApp {
private app: IApp | undefined;
private mainWindow: BrowserWindow | undefined;
/**
*
*/
private readonly onRendererMessage = async (event: Electron.MessageEvent): Promise<void> => {
const { senderId, requestId, path, method, body }: IRequest = event.data;
const channels = this.socket.get(senderId);
if(!channels) {
Logger.error(`No message channel found for sender ID: ${senderId}`);
return;
}
try {
const request = new Request(event, senderId, requestId, method, path, body);
const response = await this.router.handle(request);
channels.request.port1.postMessage(response);
}
catch(err: any) {
const response: IResponse = {
requestId,
status: 500,
body: null,
error: err.message || 'Internal Server Error',
};
channels.request.port1.postMessage(response);
}
};
constructor(
private readonly router: Router,
private readonly socket: NoxSocket,
) {}
/**
* Initializes the NoxApp instance.
* This method sets up the IPC communication, registers event listeners,
* and prepares the application for use.
*/
public async init(): Promise<NoxApp> {
ipcMain.on('gimme-my-port', this.giveTheRendererAPort.bind(this));
app.once('activate', this.onAppActivated.bind(this));
app.once('window-all-closed', this.onAllWindowsClosed.bind(this));
console.log(''); // create a new line in the console to separate setup logs from the future logs
return this;
}
/**
* Handles the request from the renderer process.
* This method creates a Request object from the IPC event data,
* processes it through the Router, and sends the response back
* to the renderer process using the MessageChannel.
*/
private giveTheRendererAPort(event: Electron.IpcMainInvokeEvent): void {
const senderId = event.sender.id;
if(this.socket.get(senderId)) {
this.shutdownChannel(senderId);
}
const requestChannel = new MessageChannelMain();
const socketChannel = new MessageChannelMain();
requestChannel.port1.on('message', this.onRendererMessage);
requestChannel.port1.start();
socketChannel.port1.start();
this.socket.register(senderId, requestChannel, socketChannel);
event.sender.postMessage('port', { senderId }, [requestChannel.port2, socketChannel.port2]);
}
/**
* MacOS specific behavior.
*/
private onAppActivated(): void {
if(process.platform === 'darwin' && BrowserWindow.getAllWindows().length === 0) {
this.app?.onActivated();
}
}
/**
* Shuts down the message channel for a specific sender ID.
* This method closes the IPC channel for the specified sender ID and
* removes it from the messagePorts map.
* @param channelSenderId - The ID of the sender channel to shut down.
* @param remove - Whether to remove the channel from the messagePorts map.
*/
private shutdownChannel(channelSenderId: number): void {
const channels = this.socket.get(channelSenderId);
if(!channels) {
Logger.warn(`No message channel found for sender ID: ${channelSenderId}`);
return;
}
channels.request.port1.off('message', this.onRendererMessage);
channels.request.port1.close();
channels.request.port2.close();
channels.socket.port1.close();
channels.socket.port2.close();
this.socket.unregister(channelSenderId);
}
/**
* Handles the application shutdown process.
* This method is called when all windows are closed, and it cleans up the message channels
*/
private async onAllWindowsClosed(): Promise<void> {
for(const senderId of this.socket.getSenderIds()) {
this.shutdownChannel(senderId);
}
Logger.info('All windows closed, shutting down application...');
await this.app?.dispose();
if(process.platform !== 'darwin') {
app.quit();
}
}
// ---
/**
* Sets the main BrowserWindow that was created early by bootstrapApplication.
* This window will be passed to IApp.onReady when start() is called.
* @param window - The BrowserWindow created during bootstrap.
*/
public setMainWindow(window: BrowserWindow): void {
this.mainWindow = window;
}
/**
* Registers a lazy-loaded route. The module behind this path prefix
* will only be dynamically imported when the first IPC request
* targets this prefix — like Angular's loadChildren.
*
* @example
* ```ts
* noxApp.lazy("auth", () => import("./modules/auth/auth.module.js"));
* noxApp.lazy("printing", () => import("./modules/printing/printing.module.js"));
* ```
*
* @param pathPrefix - The route prefix (e.g. "auth", "cash-register").
* @param loadModule - A function returning a dynamic import promise.
* @returns NoxApp instance for method chaining.
*/
public lazy(pathPrefix: string, loadModule: () => Promise<unknown>): NoxApp {
this.router.registerLazyRoute(pathPrefix, loadModule);
return this;
}
/**
* Eagerly loads one or more modules with a two-phase DI guarantee.
* Use this when a service needed at startup lives inside a module
* (e.g. the Application service depends on LoaderService).
*
* All dynamic imports run in parallel; bindings are registered first,
* then singletons are resolved — safe regardless of import ordering.
*
* @param importFns - Functions returning dynamic import promises.
*/
public async loadModules(importFns: Array<() => Promise<unknown>>): Promise<void> {
InjectorExplorer.beginAccumulate();
await Promise.all(importFns.map(fn => fn()));
InjectorExplorer.flushAccumulated();
}
/**
* Configures the NoxApp instance with the provided application class.
* This method allows you to set the application class that will handle lifecycle events.
* @param app - The application class to configure.
* @returns NoxApp instance for method chaining.
*/
public configure(app: Type<IApp>): NoxApp {
this.app = inject(app);
return this;
}
/**
* Registers a middleware for the root of the application.
* This method allows you to define a middleware that will be applied to all requests
* @param middleware - The middleware class to register.
* @returns NoxApp instance for method chaining.
*/
public use(middleware: Type<IMiddleware>): NoxApp {
this.router.defineRootMiddleware(middleware);
return this;
}
/**
* Should be called after the bootstrapApplication function is called.
* Passes the early-created BrowserWindow (if any) to the configured IApp service.
* @returns NoxApp instance for method chaining.
*/
public start(): NoxApp {
this.app?.onReady(this.mainWindow);
return this;
}
}