@noxfly/noxus 3.0.0-dev.2 → 3.0.0-dev.4

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.
@@ -17,6 +17,15 @@ export interface RendererClientOptions {
17
17
  initMessageType?: string;
18
18
  windowRef?: Window;
19
19
  generateRequestId?: () => string;
20
+ /**
21
+ * Timeout in milliseconds for IPC requests.
22
+ * If the main process does not respond within this duration,
23
+ * the request Promise is rejected and the pending entry cleaned up.
24
+ * Defaults to 10 000 ms. Set to 0 to disable.
25
+ */
26
+ requestTimeout?: number;
27
+ /** @default true */
28
+ enableLogging?: boolean;
20
29
  }
21
30
 
22
31
  interface PendingRequest<T = unknown> {
@@ -24,6 +33,7 @@ interface PendingRequest<T = unknown> {
24
33
  reject: (reason: IResponse<T>) => void;
25
34
  request: IRequest;
26
35
  submittedAt: number;
36
+ timer?: ReturnType<typeof setTimeout>;
27
37
  }
28
38
 
29
39
  const DEFAULT_INIT_EVENT = 'init-port';
@@ -97,18 +107,23 @@ export class NoxRendererClient {
97
107
  private readonly initMessageType: string;
98
108
  private readonly windowRef: Window;
99
109
  private readonly generateRequestId: () => string;
110
+ private readonly requestTimeout: number;
100
111
 
101
112
  private isReady = false;
102
113
  private setupPromise: Promise<void> | undefined;
103
114
  private setupResolve: (() => void) | undefined;
104
115
  private setupReject: ((reason: Error) => void) | undefined;
105
116
 
117
+ private enableLogging: boolean;
118
+
106
119
  constructor(options: RendererClientOptions = {}) {
107
120
  this.windowRef = options.windowRef ?? window;
108
121
  const resolvedBridge = options.bridge ?? resolveBridgeFromWindow(this.windowRef, options.bridgeName);
109
122
  this.bridge = resolvedBridge ?? null;
110
123
  this.initMessageType = options.initMessageType ?? DEFAULT_INIT_EVENT;
111
124
  this.generateRequestId = options.generateRequestId ?? defaultRequestId;
125
+ this.requestTimeout = options.requestTimeout ?? 10_000;
126
+ this.enableLogging = options.enableLogging ?? true;
112
127
  }
113
128
 
114
129
  public async setup(): Promise<void> {
@@ -146,6 +161,12 @@ export class NoxRendererClient {
146
161
  this.senderId = undefined;
147
162
  this.isReady = false;
148
163
 
164
+ for(const pending of this.pendingRequests.values()) {
165
+ if(pending.timer !== undefined) {
166
+ clearTimeout(pending.timer);
167
+ }
168
+ }
169
+
149
170
  this.pendingRequests.clear();
150
171
  }
151
172
 
@@ -177,6 +198,13 @@ export class NoxRendererClient {
177
198
  submittedAt: Date.now(),
178
199
  };
179
200
 
201
+ if(this.requestTimeout > 0) {
202
+ pending.timer = setTimeout(() => {
203
+ this.pendingRequests.delete(message.requestId);
204
+ reject(this.createErrorResponse<TResponse>(message.requestId, `Request timed out after ${this.requestTimeout}ms`) as IResponse<TResponse>);
205
+ }, this.requestTimeout);
206
+ }
207
+
180
208
  this.pendingRequests.set(message.requestId, pending as PendingRequest);
181
209
 
182
210
  this.requestPort!.postMessage(message);
@@ -260,6 +288,10 @@ export class NoxRendererClient {
260
288
  return;
261
289
  }
262
290
 
291
+ if(pending.timer !== undefined) {
292
+ clearTimeout(pending.timer);
293
+ }
294
+
263
295
  this.pendingRequests.delete(response.requestId);
264
296
 
265
297
  this.onRequestCompleted(pending, response);
@@ -273,6 +305,10 @@ export class NoxRendererClient {
273
305
  };
274
306
 
275
307
  protected onRequestCompleted(pending: PendingRequest, response: IResponse): void {
308
+ if(!this.enableLogging) {
309
+ return;
310
+ }
311
+
276
312
  if(typeof console.groupCollapsed === 'function') {
277
313
  console.groupCollapsed(`${response.status} ${pending.request.method} /${pending.request.path}`);
278
314
  }
@@ -17,6 +17,7 @@ export class Request {
17
17
  public readonly context: AppInjector = RootInjector.createScope();
18
18
 
19
19
  public readonly params: Record<string, string> = {};
20
+ public readonly query: Record<string, string>;
20
21
 
21
22
  constructor(
22
23
  public readonly event: Electron.MessageEvent,
@@ -24,9 +25,11 @@ export class Request {
24
25
  public readonly id: string,
25
26
  public readonly method: HttpMethod,
26
27
  public readonly path: string,
27
- public readonly body: any,
28
+ public readonly body: unknown,
29
+ query?: Record<string, string>,
28
30
  ) {
29
31
  this.path = path.replace(/^\/|\/$/g, '');
32
+ this.query = query ?? {};
30
33
  }
31
34
  }
32
35
 
@@ -41,6 +44,7 @@ export interface IRequest<TBody = unknown> {
41
44
  path: string;
42
45
  method: HttpMethod;
43
46
  body?: TBody;
47
+ query?: Record<string, string>;
44
48
  }
45
49
 
46
50
  export interface IBatchRequestItem<TBody = unknown> {
@@ -48,6 +52,7 @@ export interface IBatchRequestItem<TBody = unknown> {
48
52
  path: string;
49
53
  method: AtomicHttpMethod;
50
54
  body?: TBody;
55
+ query?: Record<string, string>;
51
56
  }
52
57
 
53
58
  export interface IBatchRequestPayload {
@@ -119,6 +119,18 @@ export class Router {
119
119
  return this;
120
120
  }
121
121
 
122
+ public getRegisteredRoutes(): Array<{ method: string; path: string; }> {
123
+ const allRoutes = this.routes.collectValues();
124
+ return allRoutes.map(r => ({ method: r.method, path: r.path }));
125
+ }
126
+
127
+ public getLazyRoutes(): Array<{ prefix: string; loaded: boolean; }> {
128
+ return [...this.lazyRoutes.entries()].map(([prefix, entry]) => ({
129
+ prefix,
130
+ loaded: entry.loaded,
131
+ }));
132
+ }
133
+
122
134
  // -------------------------------------------------------------------------
123
135
  // Request handling
124
136
  // -------------------------------------------------------------------------
@@ -167,7 +179,7 @@ export class Router {
167
179
  response.body!.responses = await Promise.all(
168
180
  payload.requests.map((item, i) => {
169
181
  const id = item.requestId ?? `${request.id}:${i}`;
170
- return this.handleAtomic(new Request(request.event, request.senderId, id, item.method, item.path, item.body));
182
+ return this.handleAtomic(new Request(request.event, request.senderId, id, item.method, item.path, item.body, item.query));
171
183
  }),
172
184
  );
173
185
  }
@@ -225,7 +237,7 @@ export class Router {
225
237
  entry.loading = null;
226
238
  entry.load = null;
227
239
 
228
- InjectorExplorer.flushAccumulated(entry.guards, entry.middlewares, prefix);
240
+ await InjectorExplorer.flushAccumulated(entry.guards, entry.middlewares, prefix);
229
241
 
230
242
  entry.loaded = true;
231
243
 
@@ -21,10 +21,12 @@ export interface RouteDefinition {
21
21
  * Dynamic import function returning the controller file.
22
22
  * The controller is loaded lazily on the first IPC request targeting this prefix.
23
23
  *
24
+ * Optional when the route only serves as a parent for `children`.
25
+ *
24
26
  * @example
25
27
  * load: () => import('./modules/users/users.controller')
26
28
  */
27
- load: () => Promise<unknown>;
29
+ load?: () => Promise<unknown>;
28
30
 
29
31
  /**
30
32
  * Guards applied to every action in this controller.
@@ -37,6 +39,12 @@ export interface RouteDefinition {
37
39
  * Merged with action-level middlewares.
38
40
  */
39
41
  middlewares?: Middleware[];
42
+
43
+ /**
44
+ * Nested child routes. Guards and middlewares declared here are
45
+ * inherited (merged) by all children.
46
+ */
47
+ children?: RouteDefinition[];
40
48
  }
41
49
 
42
50
  /**
@@ -46,6 +54,9 @@ export interface RouteDefinition {
46
54
  * This is the single source of truth for routing — no path is declared
47
55
  * in @Controller(), preventing duplicate route prefixes across controllers.
48
56
  *
57
+ * Supports nested routes via the `children` property. Guards and middlewares
58
+ * from parent entries are inherited (merged) into each child.
59
+ *
49
60
  * @example
50
61
  * export const routes = defineRoutes([
51
62
  * {
@@ -54,25 +65,78 @@ export interface RouteDefinition {
54
65
  * guards: [authGuard],
55
66
  * },
56
67
  * {
57
- * path: 'orders',
58
- * load: () => import('./modules/orders/orders.controller'),
59
- * guards: [authGuard],
60
- * middlewares: [logMiddleware],
68
+ * path: 'admin',
69
+ * guards: [authGuard, adminGuard],
70
+ * children: [
71
+ * { path: 'users', load: () => import('./admin/users.controller') },
72
+ * { path: 'products', load: () => import('./admin/products.controller') },
73
+ * ],
61
74
  * },
62
75
  * ]);
63
76
  */
64
77
  export function defineRoutes(routes: RouteDefinition[]): RouteDefinition[] {
65
- const paths = routes.map(r => r.path.replace(/^\/+|\/+$/g, ''));
66
- const duplicates = paths.filter((p, i) => paths.indexOf(p) !== i);
78
+ const flat = flattenRoutes(routes);
79
+
80
+ const paths = flat.map(r => r.path);
67
81
 
82
+ // Check exact duplicates
83
+ const duplicates = paths.filter((p, i) => paths.indexOf(p) !== i);
68
84
  if (duplicates.length > 0) {
69
85
  throw new Error(
70
86
  `[Noxus] Duplicate route prefixes detected: ${[...new Set(duplicates)].map(d => `"${d}"`).join(', ')}`
71
87
  );
72
88
  }
73
89
 
74
- return routes.map(r => ({
75
- ...r,
76
- path: r.path.replace(/^\/+|\/+$/g, ''),
77
- }));
90
+ // Check overlapping prefixes (e.g. 'users' and 'users/admin')
91
+ const sorted = [...paths].sort();
92
+ for (let i = 0; i < sorted.length - 1; i++) {
93
+ const a = sorted[i]!;
94
+ const b = sorted[i + 1]!;
95
+ if (b.startsWith(a + '/')) {
96
+ throw new Error(
97
+ `[Noxus] Overlapping route prefixes detected: "${a}" and "${b}". ` +
98
+ `Use nested children under "${a}" instead of declaring both as top-level routes.`
99
+ );
100
+ }
101
+ }
102
+
103
+ return flat;
104
+ }
105
+
106
+ /**
107
+ * Recursively flattens nested route definitions, merging parent guards / middlewares.
108
+ */
109
+ function flattenRoutes(
110
+ routes: RouteDefinition[],
111
+ parentPath = '',
112
+ parentGuards: Guard[] = [],
113
+ parentMiddlewares: Middleware[] = [],
114
+ ): RouteDefinition[] {
115
+ const result: RouteDefinition[] = [];
116
+
117
+ for (const route of routes) {
118
+ const path = [parentPath, route.path.replace(/^\/+|\/+$/g, '')]
119
+ .filter(Boolean)
120
+ .join('/');
121
+
122
+ const guards = [...new Set([...parentGuards, ...(route.guards ?? [])])];
123
+ const middlewares = [...new Set([...parentMiddlewares, ...(route.middlewares ?? [])])];
124
+
125
+ if (route.load) {
126
+ result.push({ ...route, path, guards, middlewares });
127
+ }
128
+
129
+ if (route.children?.length) {
130
+ result.push(...flattenRoutes(route.children, path, guards, middlewares));
131
+ }
132
+
133
+ if (!route.load && !route.children?.length) {
134
+ throw new Error(
135
+ `[Noxus] Route "${path}" has neither a load function nor children. ` +
136
+ `It must have at least one of them.`
137
+ );
138
+ }
139
+ }
140
+
141
+ return result;
78
142
  }
@@ -37,7 +37,7 @@ export class NoxSocket {
37
37
  return [...this.channels.keys()];
38
38
  }
39
39
 
40
- public emit<TPayload = unknown>(eventName: string, payload?: TPayload, targetSenderIds?: number[]): number {
40
+ public emit<TPayload = unknown>(eventName: string, payload?: TPayload, targetSenderIds?: number[]): void {
41
41
  const normalizedEvent = eventName.trim();
42
42
 
43
43
  if(normalizedEvent.length === 0) {
@@ -45,7 +45,6 @@ export class NoxSocket {
45
45
  }
46
46
 
47
47
  const recipients = targetSenderIds ?? this.getSenderIds();
48
- let delivered = 0;
49
48
 
50
49
  for(const senderId of recipients) {
51
50
  const channel = this.channels.get(senderId);
@@ -57,17 +56,20 @@ export class NoxSocket {
57
56
 
58
57
  try {
59
58
  channel.socket.port1.postMessage(createRendererEventMessage(normalizedEvent, payload));
60
- delivered++;
61
59
  }
62
60
  catch(error) {
63
61
  Logger.error(`[Noxus] Failed to emit "${normalizedEvent}" to sender ${senderId}.`, error);
64
62
  }
65
63
  }
66
-
67
- return delivered;
68
64
  }
69
65
 
70
66
  public emitToRenderer<TPayload = unknown>(senderId: number, eventName: string, payload?: TPayload): boolean {
71
- return this.emit(eventName, payload, [senderId]) > 0;
67
+ if(!this.channels.has(senderId)) {
68
+ return false;
69
+ }
70
+
71
+ this.emit(eventName, payload, [senderId]);
72
+
73
+ return true;
72
74
  }
73
75
  }
@@ -132,6 +132,31 @@ export class RadixTree<T> {
132
132
  return this.searchRecursive(this.root, segments, {});
133
133
  }
134
134
 
135
+ /**
136
+ * Collects all values in the subtree rooted at the given node.
137
+ * This method traverses the subtree starting from the given node and collects all values
138
+ * @param node - The node to start collecting values from.
139
+ * @param values - An array to store the collected values. This parameter is optional and can be used for recursive calls.
140
+ * @returns An array of all values found in the subtree rooted at the given node.
141
+ */
142
+ public collectValues(): T[];
143
+ public collectValues(node: RadixNode<T>, values: T[]): T[];
144
+ public collectValues(node?: RadixNode<T>, values: T[] = []): T[] {
145
+ if(!node) {
146
+ node = this.root;
147
+ }
148
+
149
+ if(node.value !== undefined) {
150
+ values.push(node.value);
151
+ }
152
+
153
+ for(const child of node.children) {
154
+ this.collectValues(child, values);
155
+ }
156
+
157
+ return values;
158
+ }
159
+
135
160
  /**
136
161
  * Recursively searches for a path in the Radix Tree.
137
162
  * This method traverses the tree and searches for the segments of the path, collecting parameters
@@ -154,40 +179,48 @@ export class RadixTree<T> {
154
179
 
155
180
  const [segment, ...rest] = segments;
156
181
 
182
+ // Try static (exact) matches first, then param matches.
183
+ // This ensures e.g. 'addNote' is preferred over ':id'.
184
+ const staticChildren: RadixNode<T>[] = [];
185
+ const paramChildren: RadixNode<T>[] = [];
186
+
157
187
  for(const child of node.children) {
158
188
  if(child.isParam) {
159
- const paramName = child.paramName!;
160
-
161
- const childParams: Params = {
162
- ...params,
163
- [paramName]: segment ?? "",
164
- };
189
+ paramChildren.push(child);
190
+ }
191
+ else if(segment === child.segment) {
192
+ staticChildren.push(child);
193
+ }
194
+ }
165
195
 
166
- if(rest.length === 0) {
167
- return {
168
- node: child,
169
- params: childParams
170
- };
196
+ for(const child of staticChildren) {
197
+ if(rest.length === 0) {
198
+ // Only return leaf-level matches (has children for method nodes, or has a value)
199
+ if(child.value !== undefined || child.children.length > 0) {
200
+ return { node: child, params };
171
201
  }
202
+ }
172
203
 
173
- const result = this.searchRecursive(child, rest, childParams);
204
+ const result = this.searchRecursive(child, rest, params);
205
+ if(result) return result;
206
+ }
174
207
 
175
- if(result)
176
- return result;
177
- }
178
- else if(segment === child.segment) {
179
- if(rest.length === 0) {
180
- return {
181
- node: child,
182
- params
183
- };
184
- }
208
+ for(const child of paramChildren) {
209
+ const paramName = child.paramName!;
185
210
 
186
- const result = this.searchRecursive(child, rest, params);
211
+ const childParams: Params = {
212
+ ...params,
213
+ [paramName]: segment ?? "",
214
+ };
187
215
 
188
- if(result)
189
- return result;
216
+ if(rest.length === 0) {
217
+ if(child.value !== undefined || child.children.length > 0) {
218
+ return { node: child, params: childParams };
219
+ }
190
220
  }
221
+
222
+ const result = this.searchRecursive(child, rest, childParams);
223
+ if(result) return result;
191
224
  }
192
225
 
193
226
  return undefined;
@@ -4,7 +4,7 @@
4
4
  * @author NoxFly
5
5
  */
6
6
 
7
- import { BrowserWindow, screen } from 'electron/main';
7
+ import { BrowserWindow } from 'electron/main';
8
8
  import { Injectable } from '../decorators/injectable.decorator';
9
9
  import { Logger } from '../utils/logger';
10
10
 
@@ -30,6 +30,12 @@ export interface WindowRecord {
30
30
  id: number;
31
31
  }
32
32
 
33
+ /**
34
+ * @description
35
+ * The events emitted by WindowManager when windows are created, closed, focused, or blurred.
36
+ */
37
+ export type WindowEvent = 'created' | 'closed' | 'focused' | 'blurred';
38
+
33
39
  /**
34
40
  * WindowManager is a singleton service that centralizes BrowserWindow lifecycle.
35
41
  *
@@ -55,6 +61,8 @@ export interface WindowRecord {
55
61
  @Injectable({ lifetime: 'singleton' })
56
62
  export class WindowManager {
57
63
  private readonly _windows = new Map<number, BrowserWindow>();
64
+ private readonly listeners = new Map<WindowEvent, Set<(win: BrowserWindow) => void>>();
65
+
58
66
  private _mainWindowId: number | undefined;
59
67
 
60
68
  // -------------------------------------------------------------------------
@@ -114,15 +122,21 @@ export class WindowManager {
114
122
  * win.loadFile('index.html');
115
123
  */
116
124
  public async createSplash(
117
- options: Electron.BrowserWindowConstructorOptions & { animationDuration?: number } = {},
125
+ options: Electron.BrowserWindowConstructorOptions & {
126
+ animationDuration?: number;
127
+ expandToWorkArea?: boolean;
128
+ } = {},
118
129
  ): Promise<BrowserWindow> {
119
- const { animationDuration = 600, ...bwOptions } = options;
130
+ const {
131
+ animationDuration = 10,
132
+ expandToWorkArea = true,
133
+ ...bwOptions
134
+ } = options;
120
135
 
121
136
  const win = new BrowserWindow({
122
137
  width: 600,
123
138
  height: 600,
124
139
  center: true,
125
- frame: false,
126
140
  show: true,
127
141
  ...bwOptions,
128
142
  });
@@ -131,7 +145,10 @@ export class WindowManager {
131
145
 
132
146
  Logger.log(`[WindowManager] Splash window #${win.id} created`);
133
147
 
134
- await this._expandToWorkArea(win, animationDuration);
148
+ if(expandToWorkArea) {
149
+ await (() => new Promise((r) => setTimeout(r, 500)))();
150
+ await this._expandToWorkArea(win, animationDuration);
151
+ }
135
152
 
136
153
  return win;
137
154
  }
@@ -169,10 +186,12 @@ export class WindowManager {
169
186
  /** Closes and destroys a window by id. */
170
187
  public close(id: number): void {
171
188
  const win = this._windows.get(id);
189
+
172
190
  if (!win) {
173
191
  Logger.warn(`[WindowManager] Window #${id} not found`);
174
192
  return;
175
193
  }
194
+
176
195
  win.destroy();
177
196
  }
178
197
 
@@ -203,10 +222,27 @@ export class WindowManager {
203
222
  */
204
223
  public broadcast(channel: string, ...args: unknown[]): void {
205
224
  for (const win of this._windows.values()) {
206
- if (!win.isDestroyed()) win.webContents.send(channel, ...args);
225
+ if (!win.isDestroyed()) {
226
+ win.webContents.send(channel, ...args);
227
+ }
207
228
  }
208
229
  }
209
230
 
231
+ // -------------------------------------------------------------------------
232
+ // Events
233
+ // -------------------------------------------------------------------------
234
+
235
+ public on(event: WindowEvent, handler: (win: BrowserWindow) => void): () => void {
236
+ const set = this.listeners.get(event) ?? new Set();
237
+ set.add(handler);
238
+ this.listeners.set(event, set);
239
+ return () => set.delete(handler); // retourne unsubscribe
240
+ }
241
+
242
+ private _emit(event: WindowEvent, win: BrowserWindow): void {
243
+ this.listeners.get(event)?.forEach(h => h(win));
244
+ }
245
+
210
246
  // -------------------------------------------------------------------------
211
247
  // Private
212
248
  // -------------------------------------------------------------------------
@@ -218,10 +254,21 @@ export class WindowManager {
218
254
  this._mainWindowId = win.id;
219
255
  }
220
256
 
257
+ this._emit('created', win);
258
+
259
+ win.on('focus', () => this._emit('focused', win));
260
+ win.on('blur', () => this._emit('blurred', win));
261
+
221
262
  win.once('closed', () => {
222
263
  this._windows.delete(win.id);
223
- if (this._mainWindowId === win.id) this._mainWindowId = undefined;
264
+
265
+ if (this._mainWindowId === win.id) {
266
+ this._mainWindowId = undefined;
267
+ }
268
+
224
269
  Logger.log(`[WindowManager] Window #${win.id} closed`);
270
+
271
+ this._emit('closed', win);
225
272
  });
226
273
  }
227
274
 
@@ -232,9 +279,7 @@ export class WindowManager {
232
279
  */
233
280
  private _expandToWorkArea(win: BrowserWindow, animationDuration: number): Promise<void> {
234
281
  return new Promise((resolve) => {
235
- const { x, y, width, height } = screen.getPrimaryDisplay().workArea;
236
-
237
- win.setBounds({ x, y, width, height }, true);
282
+ win.maximize();
238
283
 
239
284
  // Wait for the animation to finish before resolving.
240
285
  // We listen to the 'resize' event which fires once the OS
@@ -242,14 +287,16 @@ export class WindowManager {
242
287
  let resolved = false;
243
288
 
244
289
  const done = (): void => {
245
- if (resolved) return;
290
+ if (resolved) {
291
+ return;
292
+ }
246
293
  resolved = true;
247
294
  win.removeListener('resize', done);
248
295
  resolve();
249
296
  };
250
297
 
251
298
  win.once('resize', done);
252
- setTimeout(done, animationDuration + 100); // safety fallback
299
+ setTimeout(done, animationDuration);
253
300
  });
254
301
  }
255
302
  }