@noxfly/noxus 2.3.2 → 2.5.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.
@@ -13,31 +13,141 @@ import { Router } from "src/router";
13
13
  import { Logger } from "src/utils/logger";
14
14
  import { Type } from "src/utils/types";
15
15
 
16
+ interface PendingRegistration {
17
+ target: Type<unknown>;
18
+ lifetime: Lifetime;
19
+ }
20
+
16
21
  /**
17
22
  * InjectorExplorer is a utility class that explores the dependency injection system at the startup.
23
+ * It collects decorated classes during the import phase and defers their actual registration
24
+ * and resolution to when {@link processPending} is called by bootstrapApplication.
18
25
  */
19
26
  export class InjectorExplorer {
27
+ private static readonly pending: PendingRegistration[] = [];
28
+ private static processed = false;
29
+ private static accumulating = false;
30
+
31
+ /**
32
+ * Enqueues a class for deferred registration.
33
+ * Called by the @Injectable decorator at import time.
34
+ *
35
+ * If {@link processPending} has already been called (i.e. after bootstrap)
36
+ * and accumulation mode is not active, the class is registered immediately
37
+ * so that late dynamic imports (e.g. middlewares loaded after bootstrap)
38
+ * work correctly.
39
+ *
40
+ * When accumulation mode is active (between {@link beginAccumulate} and
41
+ * {@link flushAccumulated}), classes are queued instead — preserving the
42
+ * two-phase binding/resolution guarantee for lazy-loaded modules.
43
+ */
44
+ public static enqueue(target: Type<unknown>, lifetime: Lifetime): void {
45
+ if(InjectorExplorer.processed && !InjectorExplorer.accumulating) {
46
+ InjectorExplorer.registerImmediate(target, lifetime);
47
+ return;
48
+ }
49
+
50
+ InjectorExplorer.pending.push({ target, lifetime });
51
+ }
52
+
53
+ /**
54
+ * Enters accumulation mode. While active, all decorated classes discovered
55
+ * via dynamic imports are queued in {@link pending} rather than registered
56
+ * immediately. Call {@link flushAccumulated} to process them with the
57
+ * full two-phase (bind-then-resolve) guarantee.
58
+ */
59
+ public static beginAccumulate(): void {
60
+ InjectorExplorer.accumulating = true;
61
+ }
62
+
63
+ /**
64
+ * Exits accumulation mode and processes every class queued since
65
+ * {@link beginAccumulate} was called. Uses the same two-phase strategy
66
+ * as {@link processPending} (register all bindings first, then resolve
67
+ * singletons / controllers) so import ordering within a lazy batch
68
+ * does not cause resolution failures.
69
+ */
70
+ public static flushAccumulated(): void {
71
+ InjectorExplorer.accumulating = false;
72
+
73
+ const queue = [...InjectorExplorer.pending];
74
+ InjectorExplorer.pending.length = 0;
75
+
76
+ // Phase 1: register all bindings without instantiation
77
+ for(const { target, lifetime } of queue) {
78
+ if(!RootInjector.bindings.has(target)) {
79
+ RootInjector.bindings.set(target, {
80
+ implementation: target,
81
+ lifetime
82
+ });
83
+ }
84
+ }
85
+
86
+ // Phase 2: resolve singletons, register controllers, log modules
87
+ for(const { target, lifetime } of queue) {
88
+ InjectorExplorer.processRegistration(target, lifetime);
89
+ }
90
+ }
91
+
20
92
  /**
21
- * Registers the class as injectable.
22
- * When a class is instantiated, if it has dependencies and those dependencies
23
- * are listed using this method, they will be injected into the class constructor.
93
+ * Processes all pending registrations in two phases:
94
+ * 1. Register all bindings (no instantiation) so every dependency is known.
95
+ * 2. Resolve singletons, register controllers and log module readiness.
96
+ *
97
+ * This two-phase approach makes the system resilient to import ordering:
98
+ * all bindings exist before any singleton is instantiated.
24
99
  */
25
- public static register(target: Type<unknown>, lifetime: Lifetime): typeof RootInjector {
26
- if(RootInjector.bindings.has(target)) // already registered
27
- return RootInjector;
100
+ public static processPending(): void {
101
+ const queue = InjectorExplorer.pending;
102
+
103
+ // Phase 1: register all bindings without instantiation
104
+ for(const { target, lifetime } of queue) {
105
+ if(!RootInjector.bindings.has(target)) {
106
+ RootInjector.bindings.set(target, {
107
+ implementation: target,
108
+ lifetime
109
+ });
110
+ }
111
+ }
112
+
113
+ // Phase 2: resolve singletons, register controllers, log modules
114
+ for(const { target, lifetime } of queue) {
115
+ InjectorExplorer.processRegistration(target, lifetime);
116
+ }
117
+
118
+ queue.length = 0;
119
+ InjectorExplorer.processed = true;
120
+ }
121
+
122
+ /**
123
+ * Registers a single class immediately (post-bootstrap path).
124
+ * Used for classes discovered via late dynamic imports.
125
+ */
126
+ private static registerImmediate(target: Type<unknown>, lifetime: Lifetime): void {
127
+ if(RootInjector.bindings.has(target)) {
128
+ return;
129
+ }
28
130
 
29
131
  RootInjector.bindings.set(target, {
30
132
  implementation: target,
31
133
  lifetime
32
134
  });
33
135
 
136
+ InjectorExplorer.processRegistration(target, lifetime);
137
+ }
138
+
139
+ /**
140
+ * Performs phase-2 work for a single registration: resolve singletons,
141
+ * register controllers, and log module readiness.
142
+ */
143
+ private static processRegistration(target: Type<unknown>, lifetime: Lifetime): void {
34
144
  if(lifetime === 'singleton') {
35
145
  RootInjector.resolve(target);
36
146
  }
37
147
 
38
148
  if(getModuleMetadata(target)) {
39
149
  Logger.log(`${target.name} dependencies initialized`);
40
- return RootInjector;
150
+ return;
41
151
  }
42
152
 
43
153
  const controllerMeta = getControllerMetadata(target);
@@ -45,20 +155,15 @@ export class InjectorExplorer {
45
155
  if(controllerMeta) {
46
156
  const router = RootInjector.resolve(Router);
47
157
  router?.registerController(target);
48
- return RootInjector;
158
+ return;
49
159
  }
50
160
 
51
- const routeMeta = getRouteMetadata(target);
52
-
53
- if(routeMeta) {
54
- return RootInjector;
161
+ if(getRouteMetadata(target).length > 0) {
162
+ return;
55
163
  }
56
164
 
57
165
  if(getInjectableMetadata(target)) {
58
166
  Logger.log(`Registered ${target.name} as ${lifetime}`);
59
- return RootInjector;
60
167
  }
61
-
62
- return RootInjector;
63
168
  }
64
169
  }
package/src/app.ts CHANGED
@@ -8,6 +8,7 @@ import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron/main";
8
8
  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
+ import { InjectorExplorer } from "src/DI/injector-explorer";
11
12
  import { IRequest, IResponse, Request } from "src/request";
12
13
  import { NoxSocket } from "src/socket";
13
14
  import { Router } from "src/router";
@@ -21,7 +22,7 @@ import { Type } from "src/utils/types";
21
22
  */
22
23
  export interface IApp {
23
24
  dispose(): Promise<void>;
24
- onReady(): Promise<void>;
25
+ onReady(mainWindow?: BrowserWindow): Promise<void>;
25
26
  onActivated(): Promise<void>;
26
27
  }
27
28
 
@@ -32,6 +33,7 @@ export interface IApp {
32
33
  @Injectable('singleton')
33
34
  export class NoxApp {
34
35
  private app: IApp | undefined;
36
+ private mainWindow: BrowserWindow | undefined;
35
37
 
36
38
  /**
37
39
  *
@@ -163,6 +165,51 @@ export class NoxApp {
163
165
 
164
166
  // ---
165
167
 
168
+ /**
169
+ * Sets the main BrowserWindow that was created early by bootstrapApplication.
170
+ * This window will be passed to IApp.onReady when start() is called.
171
+ * @param window - The BrowserWindow created during bootstrap.
172
+ */
173
+ public setMainWindow(window: BrowserWindow): void {
174
+ this.mainWindow = window;
175
+ }
176
+
177
+ /**
178
+ * Registers a lazy-loaded route. The module behind this path prefix
179
+ * will only be dynamically imported when the first IPC request
180
+ * targets this prefix — like Angular's loadChildren.
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * noxApp.lazy("auth", () => import("./modules/auth/auth.module.js"));
185
+ * noxApp.lazy("printing", () => import("./modules/printing/printing.module.js"));
186
+ * ```
187
+ *
188
+ * @param pathPrefix - The route prefix (e.g. "auth", "cash-register").
189
+ * @param loadModule - A function returning a dynamic import promise.
190
+ * @returns NoxApp instance for method chaining.
191
+ */
192
+ public lazy(pathPrefix: string, loadModule: () => Promise<unknown>): NoxApp {
193
+ this.router.registerLazyRoute(pathPrefix, loadModule);
194
+ return this;
195
+ }
196
+
197
+ /**
198
+ * Eagerly loads one or more modules with a two-phase DI guarantee.
199
+ * Use this when a service needed at startup lives inside a module
200
+ * (e.g. the Application service depends on LoaderService).
201
+ *
202
+ * All dynamic imports run in parallel; bindings are registered first,
203
+ * then singletons are resolved — safe regardless of import ordering.
204
+ *
205
+ * @param importFns - Functions returning dynamic import promises.
206
+ */
207
+ public async loadModules(importFns: Array<() => Promise<unknown>>): Promise<void> {
208
+ InjectorExplorer.beginAccumulate();
209
+ await Promise.all(importFns.map(fn => fn()));
210
+ InjectorExplorer.flushAccumulated();
211
+ }
212
+
166
213
  /**
167
214
  * Configures the NoxApp instance with the provided application class.
168
215
  * This method allows you to set the application class that will handle lifecycle events.
@@ -187,10 +234,11 @@ export class NoxApp {
187
234
 
188
235
  /**
189
236
  * Should be called after the bootstrapApplication function is called.
237
+ * Passes the early-created BrowserWindow (if any) to the configured IApp service.
190
238
  * @returns NoxApp instance for method chaining.
191
239
  */
192
240
  public start(): NoxApp {
193
- this.app?.onReady();
241
+ this.app?.onReady(this.mainWindow);
194
242
  return this;
195
243
  }
196
244
  }
package/src/bootstrap.ts CHANGED
@@ -4,29 +4,79 @@
4
4
  * @author NoxFly
5
5
  */
6
6
 
7
- import { app } from "electron/main";
7
+ import { app, BrowserWindow, screen } from "electron/main";
8
8
  import { NoxApp } from "src/app";
9
9
  import { getModuleMetadata } from "src/decorators/module.decorator";
10
10
  import { inject } from "src/DI/app-injector";
11
+ import { InjectorExplorer } from "src/DI/injector-explorer";
11
12
  import { Type } from "src/utils/types";
12
13
 
14
+ /**
15
+ * Options for bootstrapping the Noxus application.
16
+ */
17
+ export interface BootstrapOptions {
18
+ /**
19
+ * If provided, Noxus creates a BrowserWindow immediately after Electron is ready,
20
+ * before any DI processing occurs. This window is passed to the configured
21
+ * IApp service via onReady(). It allows the user to see a window as fast as possible,
22
+ * even before the application is fully initialized.
23
+ */
24
+ window?: Electron.BrowserWindowConstructorOptions;
25
+ }
26
+
13
27
  /**
14
28
  * Bootstraps the Noxus application.
15
29
  * This function initializes the application by creating an instance of NoxApp,
16
30
  * registering the root module, and starting the application.
31
+ *
32
+ * When {@link BootstrapOptions.window} is provided, a BrowserWindow is created
33
+ * immediately after Electron readiness — before DI resolution — so the user
34
+ * sees a window as quickly as possible.
35
+ *
17
36
  * @param rootModule - The root module of the application, decorated with @Module.
37
+ * @param options - Optional bootstrap configuration.
18
38
  * @return A promise that resolves to the NoxApp instance.
19
39
  * @throws Error if the root module is not decorated with @Module, or if the electron process could not start.
20
40
  */
21
- export async function bootstrapApplication(rootModule: Type<any>): Promise<NoxApp> {
22
- if(!getModuleMetadata(rootModule)) {
41
+ export async function bootstrapApplication(rootModule?: Type<any> | null, options?: BootstrapOptions): Promise<NoxApp> {
42
+ if(rootModule && !getModuleMetadata(rootModule)) {
23
43
  throw new Error(`Root module must be decorated with @Module`);
24
44
  }
25
45
 
26
46
  await app.whenReady();
27
47
 
48
+ // Create window immediately after Electron is ready, before DI processing.
49
+ // This gets pixels on screen as fast as possible.
50
+ let mainWindow: BrowserWindow | undefined;
51
+
52
+ if(options?.window) {
53
+ mainWindow = new BrowserWindow(options.window);
54
+
55
+ mainWindow.once("ready-to-show", () => {
56
+ mainWindow?.show();
57
+ });
58
+
59
+ const primaryDisplay = screen.getPrimaryDisplay();
60
+ const { width, height } = primaryDisplay.workAreaSize;
61
+
62
+ if(options.window.minWidth && options.window.minHeight) {
63
+ mainWindow.setSize(
64
+ Math.min(width, options.window.minWidth),
65
+ Math.min(height, options.window.minHeight),
66
+ true,
67
+ );
68
+ }
69
+ }
70
+
71
+ // Process all deferred injectable registrations (two-phase: bindings then resolution)
72
+ InjectorExplorer.processPending();
73
+
28
74
  const noxApp = inject(NoxApp);
29
75
 
76
+ if(mainWindow) {
77
+ noxApp.setMainWindow(mainWindow);
78
+ }
79
+
30
80
  await noxApp.init();
31
81
 
32
82
  return noxApp;
@@ -23,6 +23,6 @@ export function Injectable(lifetime: Lifetime = "scope"): ClassDecorator {
23
23
  throw new Error(`@Injectable can only be used on classes, not on ${typeof target}`);
24
24
  }
25
25
  defineInjectableMetadata(target, lifetime);
26
- InjectorExplorer.register(target as unknown as Type<any>, lifetime);
26
+ InjectorExplorer.enqueue(target as unknown as Type<any>, lifetime);
27
27
  };
28
28
  }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  export * from './request';
8
+ export * from './preload-bridge';
8
9
  export * from './renderer-events';
9
10
  export * from './renderer-client';
10
11
  export type { HttpMethod, AtomicHttpMethod } from './decorators/method.decorator';
package/src/main.ts CHANGED
@@ -22,12 +22,9 @@ export * from './decorators/injectable.decorator';
22
22
  export * from './decorators/inject.decorator';
23
23
  export * from './decorators/method.decorator';
24
24
  export * from './decorators/module.decorator';
25
- export * from './preload-bridge';
26
25
  export * from './utils/logger';
27
26
  export * from './utils/types';
28
27
  export * from './utils/forward-ref';
29
28
  export * from './request';
30
- export * from './renderer-events';
31
- export * from './renderer-client';
32
29
  export * from './socket';
33
30
 
package/src/router.ts CHANGED
@@ -12,10 +12,28 @@ import { AtomicHttpMethod, getRouteMetadata } from 'src/decorators/method.decora
12
12
  import { getMiddlewaresForController, getMiddlewaresForControllerAction, IMiddleware, NextFunction } from 'src/decorators/middleware.decorator';
13
13
  import { BadRequestException, MethodNotAllowedException, NotFoundException, ResponseException, UnauthorizedException } from 'src/exceptions';
14
14
  import { IBatchRequestItem, IBatchRequestPayload, IBatchResponsePayload, IResponse, Request } from 'src/request';
15
+ import { InjectorExplorer } from 'src/DI/injector-explorer';
15
16
  import { Logger } from 'src/utils/logger';
16
17
  import { RadixTree } from 'src/utils/radix-tree';
17
18
  import { Type } from 'src/utils/types';
18
19
 
20
+ /**
21
+ * A lazy route entry maps a path prefix to a dynamic import function.
22
+ * The module is loaded on the first request matching the prefix.
23
+ */
24
+ export interface ILazyRoute {
25
+ /** Path prefix (e.g. "auth", "printing"). Matched against the first segment(s) of the request path. */
26
+ path: string;
27
+ /** Dynamic import function returning the module file. */
28
+ loadModule: () => Promise<unknown>;
29
+ }
30
+
31
+ interface LazyRouteEntry {
32
+ loadModule: () => Promise<unknown>;
33
+ loading: Promise<void> | null;
34
+ loaded: boolean;
35
+ }
36
+
19
37
  const ATOMIC_HTTP_METHODS: ReadonlySet<AtomicHttpMethod> = new Set<AtomicHttpMethod>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
20
38
 
21
39
  function isAtomicHttpMethod(method: unknown): method is AtomicHttpMethod {
@@ -51,6 +69,7 @@ export type ControllerAction = (request: Request, response: IResponse) => any;
51
69
  export class Router {
52
70
  private readonly routes = new RadixTree<IRouteDefinition>();
53
71
  private readonly rootMiddlewares: Type<IMiddleware>[] = [];
72
+ private readonly lazyRoutes = new Map<string, LazyRouteEntry>();
54
73
 
55
74
  /**
56
75
  * Registers a controller class with the router.
@@ -109,6 +128,21 @@ export class Router {
109
128
  return this;
110
129
  }
111
130
 
131
+ /**
132
+ * Registers a lazy route. The module behind this route prefix will only
133
+ * be imported (and its controllers/services registered in DI) the first
134
+ * time a request targets this prefix.
135
+ *
136
+ * @param pathPrefix - Route prefix (e.g. "auth"). Matched against the first segment of the request path.
137
+ * @param loadModule - A function that returns a dynamic import promise.
138
+ */
139
+ public registerLazyRoute(pathPrefix: string, loadModule: () => Promise<unknown>): Router {
140
+ const normalized = pathPrefix.replace(/^\/+|\/+$/g, '');
141
+ this.lazyRoutes.set(normalized, { loadModule, loading: null, loaded: false });
142
+ Logger.log(`Registered lazy route prefix {${normalized}}`);
143
+ return this;
144
+ }
145
+
112
146
  /**
113
147
  * Defines a middleware for the root of the application.
114
148
  * This method allows you to register a middleware that will be applied to all requests
@@ -148,7 +182,7 @@ export class Router {
148
182
  let isCritical: boolean = false;
149
183
 
150
184
  try {
151
- const routeDef = this.findRoute(request);
185
+ const routeDef = await this.findRoute(request);
152
186
  await this.resolveController(request, response, routeDef);
153
187
 
154
188
  if(response.status > 400) {
@@ -352,20 +386,79 @@ export class Router {
352
386
  * @param request - The Request object containing the method and path to search for.
353
387
  * @returns The IRouteDefinition for the matched route.
354
388
  */
355
- private findRoute(request: Request): IRouteDefinition {
389
+ /**
390
+ * Attempts to find a route definition for the given request.
391
+ * Returns undefined instead of throwing when the route is not found,
392
+ * so the caller can try lazy-loading first.
393
+ */
394
+ private tryFindRoute(request: Request): IRouteDefinition | undefined {
356
395
  const matchedRoutes = this.routes.search(request.path);
357
396
 
358
397
  if(matchedRoutes?.node === undefined || matchedRoutes.node.children.length === 0) {
359
- throw new NotFoundException(`No route matches ${request.method} ${request.path}`);
398
+ return undefined;
360
399
  }
361
400
 
362
401
  const routeDef = matchedRoutes.node.findExactChild(request.method);
402
+ return routeDef?.value;
403
+ }
404
+
405
+ /**
406
+ * Finds the route definition for a given request.
407
+ * If no eagerly-registered route matches, attempts to load a lazy module
408
+ * whose prefix matches the request path, then retries.
409
+ */
410
+ private async findRoute(request: Request): Promise<IRouteDefinition> {
411
+ // Fast path: route already registered
412
+ const direct = this.tryFindRoute(request);
413
+ if(direct) return direct;
363
414
 
364
- if(routeDef?.value === undefined) {
365
- throw new MethodNotAllowedException(`Method Not Allowed for ${request.method} ${request.path}`);
415
+ // Try lazy route loading
416
+ await this.tryLoadLazyRoute(request.path);
417
+
418
+ // Retry after lazy load
419
+ const afterLazy = this.tryFindRoute(request);
420
+ if(afterLazy) return afterLazy;
421
+
422
+ throw new NotFoundException(`No route matches ${request.method} ${request.path}`);
423
+ }
424
+
425
+ /**
426
+ * Given a request path, checks whether a lazy route prefix matches
427
+ * and triggers the dynamic import if it hasn't been loaded yet.
428
+ */
429
+ private async tryLoadLazyRoute(requestPath: string): Promise<void> {
430
+ const firstSegment = requestPath.replace(/^\/+/, '').split('/')[0] ?? '';
431
+
432
+ // Check exact first segment, then try multi-segment prefixes
433
+ for(const [prefix, entry] of this.lazyRoutes) {
434
+ if(entry.loaded) continue;
435
+
436
+ const normalizedPath = requestPath.replace(/^\/+/, '');
437
+ if(normalizedPath === prefix || normalizedPath.startsWith(prefix + '/') || firstSegment === prefix) {
438
+ if(!entry.loading) {
439
+ entry.loading = this.loadLazyModule(prefix, entry);
440
+ }
441
+ await entry.loading;
442
+ return;
443
+ }
366
444
  }
445
+ }
446
+
447
+ /**
448
+ * Dynamically imports a lazy module and registers its decorated classes
449
+ * (controllers, services) in the DI container using the two-phase strategy.
450
+ */
451
+ private async loadLazyModule(prefix: string, entry: LazyRouteEntry): Promise<void> {
452
+ const t0 = performance.now();
453
+
454
+ InjectorExplorer.beginAccumulate();
455
+ await entry.loadModule();
456
+ InjectorExplorer.flushAccumulated();
457
+
458
+ entry.loaded = true;
367
459
 
368
- return routeDef.value;
460
+ const t1 = performance.now();
461
+ Logger.info(`Lazy-loaded module for prefix {${prefix}} in ${Math.round(t1 - t0)}ms`);
369
462
  }
370
463
 
371
464
  /**