Lightweight IPC framework for Electron, inspired by NestJS and Angular. It simulates HTTP-like requests between the renderer and the main process via MessageChannel / MessagePort, with routing, DI, guards, middlewares, and lazy-loading.
No dependency on reflect-metadata or emitDecoratorMetadata.
npm install @noxfly/noxusIn your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": false
}
}| Concept | Role |
|---|---|
| Controller | Handles a set of IPC routes under a path prefix |
| Injectable | Service injectable into other services or controllers |
| Guard | Protects a route or controller (authorization) |
| Middleware | Runs before guards and the handler |
| Token | Explicit identifier for non-class dependencies |
| WindowManager | Singleton service for managing Electron windows |
| bootstrapApplication | Single entry point of the application |
// services/user.service.ts
import { Injectable } from '@noxfly/noxus/main';
@Injectable({ lifetime: 'singleton' })
export class UserService {
private users = [{ id: 1, name: 'Alice' }];
findAll() {
return this.users;
}
findById(id: number) {
return this.users.find(u => u.id === id);
}
}// controllers/user.controller.ts
import { Controller, Get, Post, Request } from '@noxfly/noxus/main';
import { UserService } from '../services/user.service';
@Controller({ path: 'users', deps: [UserService] })
export class UserController {
constructor(private svc: UserService) {}
@Get('list')
list(req: Request) {
return this.svc.findAll();
}
@Get(':id')
getOne(req: Request) {
const id = parseInt(req.params['id']!);
return this.svc.findById(id);
}
@Post('create')
create(req: Request) {
return { created: true, body: req.body };
}
}// app.service.ts
import { IApp, Injectable, WindowManager } from '@noxfly/noxus/main';
import path from 'path';
@Injectable({ lifetime: 'singleton', deps: [WindowManager] })
export class AppService implements IApp {
constructor(private wm: WindowManager) {}
async onReady() {
const win = await this.wm.createSplash({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
win.loadFile('index.html');
}
async onActivated() {
if (this.wm.count === 0) await this.onReady();
}
async dispose() {
// cleanup on app close
}
}// routes.ts
import { defineRoutes } from '@noxfly/noxus/main';
export const routes = defineRoutes([
{ path: 'users', load: () => import('./controllers/user.controller.js') },
{ path: 'orders', load: () => import('./controllers/order.controller.js') },
]);// main.ts
import { bootstrapApplication } from '@noxfly/noxus/main';
import { AppService } from './app.service';
import { routes } from './routes';
const noxApp = await bootstrapApplication({ routes });
noxApp
.configure(AppService)
.start();@Injectable({ lifetime: 'singleton', deps: [RepoA, RepoB] })
class MyService {
constructor(private a: RepoA, private b: RepoB) {}
}| Option | Type | Default | Description |
|---|---|---|---|
lifetime |
'singleton' | 'scope' | 'transient' |
'scope' |
Instance lifetime |
deps |
TokenKey[] |
[] |
Constructor dependencies, in order |
Lifetimes:
singleton— one instance for the entire lifetime of the appscope— one instance per IPC requesttransient— a new instance on every resolution
To inject values that are not classes (strings, interfaces, config objects):
// tokens.ts
import { token } from '@noxfly/noxus/main';
export const DB_URL = token<string>('DB_URL');
export const APP_CONFIG = token<AppConfig>('APP_CONFIG');// Declaring the dependency
@Injectable({ deps: [DB_URL, APP_CONFIG] })
class DbService {
constructor(private url: string, private config: AppConfig) {}
}
// Providing the value in bootstrapApplication
bootstrapApplication({
singletons: [
{ token: DB_URL, useValue: process.env.DATABASE_URL! },
{ token: APP_CONFIG, useValue: { debug: true } },
],
});import { inject } from '@noxfly/noxus/main';
const userService = inject(UserService);When you decide to inject manually with inject(), you may not duplicate the dependency in the deps array of @Injectable.
The resolve dependency in deps will be ignored in the constructor and will be re-solved at runtime by inject().
Useful outside a constructor — in callbacks, factories, etc.
import { forwardRef } from '@noxfly/noxus/main';
@Injectable({ deps: [forwardRef(() => ServiceB)] })
class ServiceA {
constructor(private b: ServiceB) {}
}import { Get, Post, Put, Patch, Delete } from '@noxfly/noxus/main';
@Controller({ path: 'products', deps: [ProductService] })
class ProductController {
constructor(private svc: ProductService) {}
@Get('list') list(req: Request) { ... }
@Post('create') create(req: Request) { ... }
@Put(':id') replace(req: Request) { ... }
@Patch(':id') update(req: Request) { ... }
@Delete(':id') remove(req: Request) { ... }
}@Get('category/:categoryId/product/:productId')
getProduct(req: Request) {
const { categoryId, productId } = req.params;
}// Renderer side:
await client.request({ method: 'GET', path: 'users/list', query: { role: 'admin', page: '1' } });
// Controller side:
@Get('list')
list(req: Request) {
const role = req.query['role']; // 'admin'
const page = req.query['page']; // '1'
return this.svc.findAll({ role, page: parseInt(page!) });
}@Post('create')
create(req: Request) {
const { name, price } = req.body as { name: string; price: number };
}The value returned by the handler is automatically placed in response.body. To control the status code:
@Post('create')
create(req: Request, res: IResponse) {
res.status = 201;
return { id: 42 };
}defineRoutes is the single source of truth for your routing table. It validates prefixes, detects duplicates and overlapping paths, and supports nested routes:
import { defineRoutes } from '@noxfly/noxus/main';
export const routes = defineRoutes([
{ path: 'users', load: () => import('./modules/users/users.controller.js'), guards: [authGuard] },
{ path: 'orders', load: () => import('./modules/orders/orders.controller.js') },
]);Parent routes can omit load and only serve as a shared prefix with inherited guards/middlewares:
export const routes = defineRoutes([
{
path: 'admin',
guards: [authGuard, adminGuard],
children: [
{ path: 'users', load: () => import('./admin/users.controller.js') },
{ path: 'products', load: () => import('./admin/products.controller.js') },
],
},
]);
// Produces flat routes: admin/users and admin/products, both inheriting authGuard + adminGuard.This is the core mechanism for keeping startup fast. A lazy controller is never imported until an IPC request targets its prefix.
Routes declared via defineRoutes are lazy by default. You can also register lazy routes manually:
noxApp
.lazy('users', () => import('./modules/users/users.controller.js'))
.lazy('orders', () => import('./modules/orders/orders.controller.js'))
.lazy('printing', () => import('./modules/printing/printing.controller.js'))
.start();Important: the
import()argument must not statically reference heavy modules. Ifusers.controller.tsimportsapplicationinsightsat the top of the file, the library will be loaded on the firstusers/*request — not at startup.
For modules whose services are needed before onReady():
bootstrapApplication({
eagerLoad: [
() => import('./modules/auth/auth.controller.js'),
],
});await noxApp.load([
() => import('./modules/reporting/reporting.controller.js'),
]);A guard is a plain function that decides whether a request can reach its handler.
// guards/auth.guard.ts
import { Guard } from '@noxfly/noxus/main';
export const authGuard: Guard = async (req) => {
return req.body?.token === 'secret'; // your auth logic
};On an entire controller:
@Controller({ path: 'admin', deps: [AdminService], guards: [authGuard] })
class AdminController { ... }On a specific route:
@Delete(':id', { guards: [authGuard, adminGuard] })
remove(req: Request) { ... }Controller guards and route guards are cumulative — both run, in the given order.
A middleware is a plain function that runs before guards.
// middlewares/log.middleware.ts
import { Middleware } from '@noxfly/noxus/main';
export const logMiddleware: Middleware = async (req, res, next) => {
console.log(`→ ${req.method} ${req.path}`);
await next();
console.log(`← ${res.status}`);
};Global (all routes):
noxApp.use(logMiddleware);On a controller:
@Controller({ path: 'users', deps: [...], middlewares: [logMiddleware] })
class UserController { ... }On a route:
@Post('upload', { middlewares: [fileSizeMiddleware] })
upload(req: Request) { ... }Execution order: global middlewares → controller middlewares → route middlewares → guards → handler.
Injectable singleton service for managing BrowserWindow instances.
@Injectable({ lifetime: 'singleton', deps: [WindowManager] })
class AppService implements IApp {
constructor(private wm: WindowManager) {}
async onReady() {
// Creates a 600×600 window, animates it to full screen,
// then resolves the promise once the animation is complete.
// loadFile() is therefore always called at the correct size — no viewbox freeze.
const win = await this.wm.createSplash({
webPreferences: { preload: path.join(__dirname, 'preload.js') },
});
win.loadFile('index.html');
}
}// Creation
const win = await wm.createSplash(options); // animated main window
const win2 = await wm.create(config, isMain?); // custom window
// Access
wm.getMain() // main window
wm.getById(id) // by Electron id
wm.getAll() // all open windows
wm.count // number of open windows
// Actions
wm.close(id) // close a window
wm.closeAll() // close all windows
// Messaging
wm.send(id, 'channel', ...args) // send a message to one window
wm.broadcast('channel', ...args) // send to all windowscreateSplash |
create |
|
|---|---|---|
| Initial size | 600×600 centered | Whatever you define |
| Animation | Expands to work area | Optional (expandToWorkArea: true) |
show |
true immediately |
false until ready-to-show |
| Use case | Main window at startup | Secondary windows |
To inject values built outside the DI container (DB connection, third-party SDK):
// main.ts
import { MikroORM } from '@mikro-orm/core';
import { bootstrapApplication } from '@noxfly/noxus/main';
const orm = await MikroORM.init(ormConfig);
const noxApp = await bootstrapApplication({
singletons: [
{ token: MikroORM, useValue: orm },
],
});These values are then available via injection in any service:
@Injectable({ lifetime: 'singleton', deps: [MikroORM] })
class UserRepository {
constructor(private orm: MikroORM) {}
}// preload.ts
import { exposeNoxusBridge } from '@noxfly/noxus/preload';
exposeNoxusBridge(); // exposes window.__noxus__ to the renderer// In the renderer (Angular, React, Vue, Vanilla...)
import { NoxRendererClient } from '@noxfly/noxus';
const client = new NoxRendererClient({
requestTimeout: 10_000, // 10s (default). Set to 0 to disable.
});
await client.setup(); // requests the MessagePort from main
// Requests
const users = await client.request<User[]>({ method: 'GET', path: 'users/list' });
const user = await client.request<User> ({ method: 'GET', path: 'users/42' });
await client.request({ method: 'GET', path: 'users/list', query: { role: 'admin' } });
await client.request({ method: 'POST', path: 'users/create', body: { name: 'Bob' } });
await client.request({ method: 'PUT', path: 'users/42', body: { name: 'Bob Updated' } });
await client.request({ method: 'DELETE', path: 'users/42' });
// Per-request timeout override (takes precedence over the global requestTimeout)
const report = await client.request<Report>(
{ method: 'GET', path: 'reports/heavy' },
{ timeout: 60_000 }, // 60s for this specific request
);On the main side, via NoxSocket:
@Injectable({ lifetime: 'singleton', deps: [NoxSocket] })
class NotificationService {
constructor(private socket: NoxSocket) {}
notifyAll(message: string) {
this.socket.emit('notification', { message });
}
notifyOne(senderId: number, message: string) {
this.socket.emitToRenderer(senderId, 'notification', { message });
}
}On the renderer side:
const sub = client.events.subscribe<INotification>('notification', (payload) => {
console.log(payload.message);
});
// Unsubscribe when done
sub.unsubscribe();Multiple IPC requests in a single round-trip:
const results = await client.batch([
{ method: 'GET', path: 'users/list', query: { role: 'admin' } },
{ method: 'GET', path: 'products/list' },
{ method: 'POST', path: 'orders/create', body: { ... } },
]);Noxus provides an HTTP exception hierarchy to throw from handlers:
import {
BadRequestException, // 400
UnauthorizedException, // 401
ForbiddenException, // 403
NotFoundException, // 404
ConflictException, // 409
InternalServerException, // 500
// ... and all other 4xx/5xx
} from '@noxfly/noxus/main';
@Get(':id')
getOne(req: Request) {
const user = this.svc.findById(parseInt(req.params['id']!));
if (!user) throw new NotFoundException(`User not found`);
return user;
}The exception is automatically caught by the router and translated into a response with the correct HTTP status.
src/
├── main.ts ← bootstrapApplication + lazy routes
├── app.service.ts ← implements IApp
├── modules/
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ └── user.repository.ts
│ ├── orders/
│ │ ├── order.controller.ts
│ │ └── order.service.ts
│ └── printing/
│ ├── printing.controller.ts
│ └── printing.service.ts
├── guards/
│ └── auth.guard.ts
├── middlewares/
│ └── log.middleware.ts
└── tokens.ts ← shared named tokens
Each module/ folder is self-contained — the controller imports its own services directly, with no central declaration. main.ts only knows the lazy loading paths.
By default, all framework logs are enabled (debug level). Control verbosity via bootstrapApplication:
const noxApp = await bootstrapApplication({
logLevel: 'none', // 'debug' | 'info' | 'none'
});You can also pass an array of specific levels:
bootstrapApplication({ logLevel: ['warn', 'error', 'critical'] });Or change it at runtime:
import { Logger } from '@noxfly/noxus/main';
Logger.setLogLevel('info');To reset the DI container between tests (avoids leaking singletons across test suites):
import { resetRootInjector } from '@noxfly/noxus/main';
afterEach(() => {
resetRootInjector();
});This clears all bindings, singletons, and scoped instances from the root injector.