@noxfly/noxus 3.0.0-dev.3 → 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;
@@ -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
  // -------------------------------------------------------------------------
@@ -178,10 +186,12 @@ export class WindowManager {
178
186
  /** Closes and destroys a window by id. */
179
187
  public close(id: number): void {
180
188
  const win = this._windows.get(id);
189
+
181
190
  if (!win) {
182
191
  Logger.warn(`[WindowManager] Window #${id} not found`);
183
192
  return;
184
193
  }
194
+
185
195
  win.destroy();
186
196
  }
187
197
 
@@ -218,6 +228,21 @@ export class WindowManager {
218
228
  }
219
229
  }
220
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
+
221
246
  // -------------------------------------------------------------------------
222
247
  // Private
223
248
  // -------------------------------------------------------------------------
@@ -229,12 +254,21 @@ export class WindowManager {
229
254
  this._mainWindowId = win.id;
230
255
  }
231
256
 
257
+ this._emit('created', win);
258
+
259
+ win.on('focus', () => this._emit('focused', win));
260
+ win.on('blur', () => this._emit('blurred', win));
261
+
232
262
  win.once('closed', () => {
233
263
  this._windows.delete(win.id);
264
+
234
265
  if (this._mainWindowId === win.id) {
235
266
  this._mainWindowId = undefined;
236
267
  }
268
+
237
269
  Logger.log(`[WindowManager] Window #${win.id} closed`);
270
+
271
+ this._emit('closed', win);
238
272
  });
239
273
  }
240
274