@noxfly/noxus 1.1.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.ts CHANGED
@@ -9,6 +9,7 @@ import { Injectable } from "src/decorators/injectable.decorator";
9
9
  import { IMiddleware } from "src/decorators/middleware.decorator";
10
10
  import { inject } from "src/DI/app-injector";
11
11
  import { IRequest, IResponse, Request } from "src/request";
12
+ import { NoxSocket } from "src/socket";
12
13
  import { Router } from "src/router";
13
14
  import { Logger } from "src/utils/logger";
14
15
  import { Type } from "src/utils/types";
@@ -30,11 +31,37 @@ export interface IApp {
30
31
  */
31
32
  @Injectable('singleton')
32
33
  export class NoxApp {
33
- private readonly messagePorts = new Map<number, Electron.MessageChannelMain>();
34
34
  private app: IApp | undefined;
35
+ private readonly onRendererMessage = async (event: Electron.MessageEvent): Promise<void> => {
36
+ const { senderId, requestId, path, method, body }: IRequest = event.data;
37
+
38
+ const channel = this.socket.get(senderId);
39
+
40
+ if(!channel) {
41
+ Logger.error(`No message channel found for sender ID: ${senderId}`);
42
+ return;
43
+ }
44
+
45
+ try {
46
+ const request = new Request(event, requestId, method, path, body);
47
+ const response = await this.router.handle(request);
48
+ channel.port1.postMessage(response);
49
+ }
50
+ catch(err: any) {
51
+ const response: IResponse = {
52
+ requestId,
53
+ status: 500,
54
+ body: null,
55
+ error: err.message || 'Internal Server Error',
56
+ };
57
+
58
+ channel.port1.postMessage(response);
59
+ }
60
+ };
35
61
 
36
62
  constructor(
37
63
  private readonly router: Router,
64
+ private readonly socket: NoxSocket,
38
65
  ) {}
39
66
 
40
67
  /**
@@ -62,47 +89,18 @@ export class NoxApp {
62
89
  private giveTheRendererAPort(event: Electron.IpcMainInvokeEvent): void {
63
90
  const senderId = event.sender.id;
64
91
 
65
- if(this.messagePorts.has(senderId)) {
92
+ if(this.socket.get(senderId)) {
66
93
  this.shutdownChannel(senderId);
67
94
  }
68
95
 
69
96
  const channel = new MessageChannelMain();
70
- this.messagePorts.set(senderId, channel);
71
97
 
72
- channel.port1.on('message', this.onRendererMessage.bind(this));
98
+ channel.port1.on('message', this.onRendererMessage);
73
99
  channel.port1.start();
74
100
 
75
- event.sender.postMessage('port', { senderId }, [channel.port2]);
76
- }
77
-
78
- /**
79
- * Electron specific message handling.
80
- * Replaces HTTP calls by using Electron's IPC mechanism.
81
- */
82
- private async onRendererMessage(event: Electron.MessageEvent): Promise<void> {
83
- const { senderId, requestId, path, method, body }: IRequest = event.data;
84
-
85
- const channel = this.messagePorts.get(senderId);
86
-
87
- if(!channel) {
88
- Logger.error(`No message channel found for sender ID: ${senderId}`);
89
- return;
90
- }
91
- try {
92
- const request = new Request(event, requestId, method, path, body);
93
- const response = await this.router.handle(request);
94
- channel.port1.postMessage(response);
95
- }
96
- catch(err: any) {
97
- const response: IResponse = {
98
- requestId,
99
- status: 500,
100
- body: null,
101
- error: err.message || 'Internal Server Error',
102
- };
101
+ this.socket.register(senderId, channel);
103
102
 
104
- channel.port1.postMessage(response);
105
- }
103
+ event.sender.postMessage('port', { senderId }, [channel.port2]);
106
104
  }
107
105
 
108
106
  /**
@@ -122,18 +120,18 @@ export class NoxApp {
122
120
  * @param remove - Whether to remove the channel from the messagePorts map.
123
121
  */
124
122
  private shutdownChannel(channelSenderId: number): void {
125
- const channel = this.messagePorts.get(channelSenderId);
123
+ const channel = this.socket.get(channelSenderId);
126
124
 
127
125
  if(!channel) {
128
126
  Logger.warn(`No message channel found for sender ID: ${channelSenderId}`);
129
127
  return;
130
128
  }
131
129
 
132
- channel.port1.off('message', this.onRendererMessage.bind(this));
130
+ channel.port1.off('message', this.onRendererMessage);
133
131
  channel.port1.close();
134
132
  channel.port2.close();
135
133
 
136
- this.messagePorts.delete(channelSenderId);
134
+ this.socket.unregister(channelSenderId);
137
135
  }
138
136
 
139
137
  /**
@@ -141,11 +139,9 @@ export class NoxApp {
141
139
  * This method is called when all windows are closed, and it cleans up the message channels
142
140
  */
143
141
  private async onAllWindowsClosed(): Promise<void> {
144
- this.messagePorts.forEach((channel, senderId) => {
142
+ for(const senderId of this.socket.getSenderIds()) {
145
143
  this.shutdownChannel(senderId);
146
- });
147
-
148
- this.messagePorts.clear();
144
+ }
149
145
 
150
146
  Logger.info('All windows closed, shutting down application...');
151
147
  await this.app?.dispose();
@@ -23,7 +23,12 @@ export interface IRouteMetadata {
23
23
  /**
24
24
  * The different HTTP methods that can be used in the application.
25
25
  */
26
- export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
26
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'BATCH';
27
+
28
+ /**
29
+ * Atomic HTTP verbs supported by controllers. BATCH is handled at the router level only.
30
+ */
31
+ export type AtomicHttpMethod = Exclude<HttpMethod, 'BATCH'>;
27
32
 
28
33
  /**
29
34
  * The configuration that waits a route's decorator.
package/src/index.ts CHANGED
@@ -4,17 +4,5 @@
4
4
  * @author NoxFly
5
5
  */
6
6
 
7
- export * from './DI/app-injector';
8
- export * from './router';
9
- export * from './app';
10
- export * from './bootstrap';
11
- export * from './exceptions';
12
- export * from './decorators/middleware.decorator';
13
- export * from './decorators/guards.decorator';
14
- export * from './decorators/controller.decorator';
15
- export * from './decorators/injectable.decorator';
16
- export * from './decorators/method.decorator';
17
- export * from './decorators/module.decorator';
18
- export * from './utils/logger';
19
- export * from './utils/types';
20
7
  export * from './request';
8
+ export * from './renderer-events';
package/src/main.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @copyright 2025 NoxFly
3
+ * @license MIT
4
+ * @author NoxFly
5
+ */
6
+
7
+ /**
8
+ * Entry point for Electron main-process consumers.
9
+ */
10
+ export * from './DI/app-injector';
11
+ export * from './router';
12
+ export * from './app';
13
+ export * from './bootstrap';
14
+ export * from './exceptions';
15
+ export * from './decorators/middleware.decorator';
16
+ export * from './decorators/guards.decorator';
17
+ export * from './decorators/controller.decorator';
18
+ export * from './decorators/injectable.decorator';
19
+ export * from './decorators/method.decorator';
20
+ export * from './decorators/module.decorator';
21
+ export * from './utils/logger';
22
+ export * from './utils/types';
23
+ export * from './request';
24
+ export * from './renderer-events';
25
+ export * from './socket';
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @copyright 2025 NoxFly
3
+ * @license MIT
4
+ * @author NoxFly
5
+ */
6
+
7
+ /**
8
+ * Lightweight event registry to help renderer processes subscribe to
9
+ * push messages sent by the main process through Noxus.
10
+ */
11
+ import { IRendererEventMessage, isRendererEventMessage } from 'src/request';
12
+
13
+ export type RendererEventHandler<TPayload = unknown> = (payload: TPayload) => void;
14
+
15
+ export interface RendererEventSubscription {
16
+ unsubscribe(): void;
17
+ }
18
+
19
+ export class RendererEventRegistry {
20
+ private readonly listeners = new Map<string, Set<RendererEventHandler>>();
21
+
22
+ /**
23
+ *
24
+ */
25
+ public subscribe<TPayload>(eventName: string, handler: RendererEventHandler<TPayload>): RendererEventSubscription {
26
+ const normalizedEventName = eventName.trim();
27
+
28
+ if(normalizedEventName.length === 0) {
29
+ throw new Error('Renderer event name must be a non-empty string.');
30
+ }
31
+
32
+ const handlers = this.listeners.get(normalizedEventName) ?? new Set<RendererEventHandler>();
33
+
34
+ handlers.add(handler as RendererEventHandler);
35
+ this.listeners.set(normalizedEventName, handlers);
36
+
37
+ return {
38
+ unsubscribe: () => this.unsubscribe(normalizedEventName, handler as RendererEventHandler),
39
+ };
40
+ }
41
+
42
+ /**
43
+ *
44
+ */
45
+ public unsubscribe<TPayload>(eventName: string, handler: RendererEventHandler<TPayload>): void {
46
+ const handlers = this.listeners.get(eventName);
47
+
48
+ if(!handlers) {
49
+ return;
50
+ }
51
+
52
+ handlers.delete(handler as RendererEventHandler);
53
+
54
+ if(handlers.size === 0) {
55
+ this.listeners.delete(eventName);
56
+ }
57
+ }
58
+
59
+ /**
60
+ *
61
+ */
62
+ public clear(eventName?: string): void {
63
+ if(eventName) {
64
+ this.listeners.delete(eventName);
65
+ return;
66
+ }
67
+
68
+ this.listeners.clear();
69
+ }
70
+
71
+ /**
72
+ *
73
+ */
74
+ public dispatch<TPayload>(message: IRendererEventMessage<TPayload>): void {
75
+ const handlers = this.listeners.get(message.event);
76
+
77
+ if(!handlers || handlers.size === 0) {
78
+ return;
79
+ }
80
+
81
+ handlers.forEach((handler) => {
82
+ try {
83
+ handler(message.payload as TPayload);
84
+ }
85
+ catch(error) {
86
+ console.error(`[Noxus] Renderer event handler for "${message.event}" threw an error.`, error);
87
+ }
88
+ });
89
+ }
90
+
91
+ /**
92
+ *
93
+ */
94
+ public tryDispatchFromMessageEvent(event: MessageEvent): boolean {
95
+ if(!isRendererEventMessage(event.data)) {
96
+ return false;
97
+ }
98
+
99
+ this.dispatch(event.data);
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ *
105
+ */
106
+ public hasHandlers(eventName: string): boolean {
107
+ const handlers = this.listeners.get(eventName);
108
+ return !!handlers && handlers.size > 0;
109
+ }
110
+ }
package/src/request.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import 'reflect-metadata';
8
- import { HttpMethod } from 'src/decorators/method.decorator';
8
+ import { AtomicHttpMethod, HttpMethod } from 'src/decorators/method.decorator';
9
9
  import { AppInjector, RootInjector } from 'src/DI/app-injector';
10
10
 
11
11
  /**
@@ -34,22 +34,63 @@ export class Request {
34
34
  * It includes properties for the sender ID, request ID, path, method, and an optional body.
35
35
  * This interface is used to standardize the request data across the application.
36
36
  */
37
- export interface IRequest<T = any> {
37
+ export interface IRequest<TBody = unknown> {
38
38
  senderId: number;
39
39
  requestId: string;
40
40
  path: string;
41
41
  method: HttpMethod;
42
- body?: T;
42
+ body?: TBody;
43
+ }
44
+
45
+ export interface IBatchRequestItem<TBody = unknown> {
46
+ requestId?: string;
47
+ path: string;
48
+ method: AtomicHttpMethod;
49
+ body?: TBody;
50
+ }
51
+
52
+ export interface IBatchRequestPayload {
53
+ requests: IBatchRequestItem[];
43
54
  }
44
55
 
45
56
  /**
46
57
  * Creates a Request object from the IPC event data.
47
58
  * This function extracts the necessary information from the IPC event and constructs a Request instance.
48
59
  */
49
- export interface IResponse<T = any> {
60
+ export interface IResponse<TBody = unknown> {
50
61
  requestId: string;
51
62
  status: number;
52
- body?: T;
63
+ body?: TBody;
53
64
  error?: string;
54
65
  stack?: string;
55
66
  }
67
+
68
+ export interface IBatchResponsePayload {
69
+ responses: IResponse[];
70
+ }
71
+
72
+ export const RENDERER_EVENT_TYPE = 'noxus:event';
73
+
74
+ export interface IRendererEventMessage<TPayload = unknown> {
75
+ type: typeof RENDERER_EVENT_TYPE;
76
+ event: string;
77
+ payload?: TPayload;
78
+ }
79
+
80
+ export function createRendererEventMessage<TPayload = unknown>(event: string, payload?: TPayload): IRendererEventMessage<TPayload> {
81
+ return {
82
+ type: RENDERER_EVENT_TYPE,
83
+ event,
84
+ payload,
85
+ };
86
+ }
87
+
88
+ export function isRendererEventMessage(value: unknown): value is IRendererEventMessage {
89
+ if(value === null || typeof value !== 'object') {
90
+ return false;
91
+ }
92
+
93
+ const possibleMessage = value as Partial<IRendererEventMessage>;
94
+
95
+ return possibleMessage.type === RENDERER_EVENT_TYPE && typeof possibleMessage.event === 'string';
96
+ }
package/src/router.ts CHANGED
@@ -8,14 +8,20 @@ import 'reflect-metadata';
8
8
  import { getControllerMetadata } from 'src/decorators/controller.decorator';
9
9
  import { getGuardForController, getGuardForControllerAction, IGuard } from 'src/decorators/guards.decorator';
10
10
  import { Injectable } from 'src/decorators/injectable.decorator';
11
- import { getRouteMetadata } from 'src/decorators/method.decorator';
11
+ import { AtomicHttpMethod, getRouteMetadata } from 'src/decorators/method.decorator';
12
12
  import { getMiddlewaresForController, getMiddlewaresForControllerAction, IMiddleware, NextFunction } from 'src/decorators/middleware.decorator';
13
- import { MethodNotAllowedException, NotFoundException, ResponseException, UnauthorizedException } from 'src/exceptions';
14
- import { IResponse, Request } from 'src/request';
13
+ import { BadRequestException, MethodNotAllowedException, NotFoundException, ResponseException, UnauthorizedException } from 'src/exceptions';
14
+ import { IBatchRequestItem, IBatchRequestPayload, IBatchResponsePayload, IResponse, Request } from 'src/request';
15
15
  import { Logger } from 'src/utils/logger';
16
16
  import { RadixTree } from 'src/utils/radix-tree';
17
17
  import { Type } from 'src/utils/types';
18
18
 
19
+ const ATOMIC_HTTP_METHODS: ReadonlySet<AtomicHttpMethod> = new Set<AtomicHttpMethod>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
20
+
21
+ function isAtomicHttpMethod(method: unknown): method is AtomicHttpMethod {
22
+ return typeof method === 'string' && ATOMIC_HTTP_METHODS.has(method as AtomicHttpMethod);
23
+ }
24
+
19
25
  /**
20
26
  * IRouteDefinition interface defines the structure of a route in the application.
21
27
  * It includes the HTTP method, path, controller class, handler method name,
@@ -121,6 +127,14 @@ export class Router {
121
127
  * @param channelSenderId - The ID of the sender channel to shut down.
122
128
  */
123
129
  public async handle(request: Request): Promise<IResponse> {
130
+ if(request.method === 'BATCH') {
131
+ return this.handleBatch(request);
132
+ }
133
+
134
+ return this.handleAtomic(request);
135
+ }
136
+
137
+ private async handleAtomic(request: Request): Promise<IResponse> {
124
138
  Logger.comment(`> ${request.method} /${request.path}`);
125
139
 
126
140
  const t0 = performance.now();
@@ -182,6 +196,122 @@ export class Router {
182
196
  }
183
197
  }
184
198
 
199
+ private async handleBatch(request: Request): Promise<IResponse> {
200
+ Logger.comment(`> ${request.method} /${request.path}`);
201
+
202
+ const t0 = performance.now();
203
+
204
+ const response: IResponse<IBatchResponsePayload> = {
205
+ requestId: request.id,
206
+ status: 200,
207
+ body: { responses: [] },
208
+ };
209
+
210
+ try {
211
+ const payload = this.normalizeBatchPayload(request.body);
212
+ const batchResponses: IResponse[] = [];
213
+
214
+ for(const [index, item] of payload.requests.entries()) {
215
+ const subRequestId = item.requestId ?? `${request.id}:${index}`;
216
+ const atomicRequest = new Request(request.event, subRequestId, item.method, item.path, item.body);
217
+ batchResponses.push(await this.handleAtomic(atomicRequest));
218
+ }
219
+
220
+ response.body!.responses = batchResponses;
221
+ }
222
+ catch(error: unknown) {
223
+ response.body = undefined;
224
+
225
+ if(error instanceof ResponseException) {
226
+ response.status = error.status;
227
+ response.error = error.message;
228
+ response.stack = error.stack;
229
+ }
230
+ else if(error instanceof Error) {
231
+ response.status = 500;
232
+ response.error = error.message || 'Internal Server Error';
233
+ response.stack = error.stack || 'No stack trace available';
234
+ }
235
+ else {
236
+ response.status = 500;
237
+ response.error = 'Unknown error occurred';
238
+ response.stack = 'No stack trace available';
239
+ }
240
+ }
241
+ finally {
242
+ const t1 = performance.now();
243
+
244
+ const message = `< ${response.status} ${request.method} /${request.path} ${Logger.colors.yellow}${Math.round(t1 - t0)}ms${Logger.colors.initial}`;
245
+
246
+ if(response.status < 400)
247
+ Logger.log(message);
248
+ else if(response.status < 500)
249
+ Logger.warn(message);
250
+ else
251
+ Logger.error(message);
252
+
253
+ if(response.error !== undefined) {
254
+ Logger.error(response.error);
255
+
256
+ if(response.stack !== undefined) {
257
+ Logger.errorStack(response.stack);
258
+ }
259
+ }
260
+
261
+ return response;
262
+ }
263
+ }
264
+
265
+ private normalizeBatchPayload(body: unknown): IBatchRequestPayload {
266
+ if(body === null || typeof body !== 'object') {
267
+ throw new BadRequestException('Batch payload must be an object containing a requests array.');
268
+ }
269
+
270
+ const possiblePayload = body as Partial<IBatchRequestPayload>;
271
+ const { requests } = possiblePayload;
272
+
273
+ if(!Array.isArray(requests)) {
274
+ throw new BadRequestException('Batch payload must define a requests array.');
275
+ }
276
+
277
+ const normalizedRequests = requests.map((entry, index) => this.normalizeBatchItem(entry, index));
278
+
279
+ return { requests: normalizedRequests };
280
+ }
281
+
282
+ private normalizeBatchItem(entry: unknown, index: number): IBatchRequestItem {
283
+ if(entry === null || typeof entry !== 'object') {
284
+ throw new BadRequestException(`Batch request at index ${index} must be an object.`);
285
+ }
286
+
287
+ const { requestId, path, method, body } = entry as Partial<IBatchRequestItem> & { method?: unknown };
288
+
289
+ if(requestId !== undefined && typeof requestId !== 'string') {
290
+ throw new BadRequestException(`Batch request at index ${index} has an invalid requestId.`);
291
+ }
292
+
293
+ if(typeof path !== 'string' || path.length === 0) {
294
+ throw new BadRequestException(`Batch request at index ${index} must define a non-empty path.`);
295
+ }
296
+
297
+ if(typeof method !== 'string') {
298
+ throw new BadRequestException(`Batch request at index ${index} must define an HTTP method.`);
299
+ }
300
+
301
+ const normalizedMethod = method.toUpperCase();
302
+
303
+ if(!isAtomicHttpMethod(normalizedMethod)) {
304
+ throw new BadRequestException(`Batch request at index ${index} uses the unsupported method ${method}.`);
305
+ }
306
+
307
+ return {
308
+ requestId,
309
+ path,
310
+ method: normalizedMethod as AtomicHttpMethod,
311
+ body,
312
+ };
313
+ }
314
+
185
315
  /**
186
316
  * Finds the route definition for a given request.
187
317
  * This method searches the routing tree for a matching route based on the request's path and method.
package/src/socket.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @copyright 2025 NoxFly
3
+ * @license MIT
4
+ * @author NoxFly
5
+ */
6
+
7
+ /**
8
+ * Centralizes MessagePort storage for renderer communication and handles
9
+ * push-event delivery back to renderer processes.
10
+ */
11
+ import { Injectable } from 'src/decorators/injectable.decorator';
12
+ import { createRendererEventMessage } from 'src/request';
13
+ import { Logger } from 'src/utils/logger';
14
+
15
+ @Injectable('singleton')
16
+ export class NoxSocket {
17
+ private readonly messagePorts = new Map<number, Electron.MessageChannelMain>();
18
+
19
+ public register(senderId: number, channel: Electron.MessageChannelMain): void {
20
+ this.messagePorts.set(senderId, channel);
21
+ }
22
+
23
+ public get(senderId: number): Electron.MessageChannelMain | undefined {
24
+ return this.messagePorts.get(senderId);
25
+ }
26
+
27
+ public unregister(senderId: number): void {
28
+ this.messagePorts.delete(senderId);
29
+ }
30
+
31
+ public getSenderIds(): number[] {
32
+ return [...this.messagePorts.keys()];
33
+ }
34
+
35
+ public emit<TPayload = unknown>(eventName: string, payload?: TPayload, targetSenderIds?: number[]): number {
36
+ const normalizedEvent = eventName.trim();
37
+
38
+ if(normalizedEvent.length === 0) {
39
+ throw new Error('Renderer event name must be a non-empty string.');
40
+ }
41
+
42
+ const recipients = targetSenderIds ?? this.getSenderIds();
43
+ let delivered = 0;
44
+
45
+ for(const senderId of recipients) {
46
+ const channel = this.messagePorts.get(senderId);
47
+
48
+ if(!channel) {
49
+ Logger.warn(`No message channel found for sender ID: ${senderId} while emitting "${normalizedEvent}".`);
50
+ continue;
51
+ }
52
+
53
+ try {
54
+ channel.port1.postMessage(createRendererEventMessage(normalizedEvent, payload));
55
+ delivered++;
56
+ }
57
+ catch(error) {
58
+ Logger.error(`[Noxus] Failed to emit "${normalizedEvent}" to sender ${senderId}.`, error);
59
+ }
60
+ }
61
+
62
+ return delivered;
63
+ }
64
+
65
+ public emitToRenderer<TPayload = unknown>(senderId: number, eventName: string, payload?: TPayload): boolean {
66
+ return this.emit(eventName, payload, [senderId]) > 0;
67
+ }
68
+ }
package/tsup.config.ts CHANGED
@@ -10,7 +10,8 @@ const copyrights = `
10
10
 
11
11
  export default defineConfig({
12
12
  entry: {
13
- noxus: "src/index.ts"
13
+ renderer: "src/index.ts",
14
+ main: "src/main.ts",
14
15
  },
15
16
  keepNames: true,
16
17
  minifyIdentifiers: false,