@noxfly/noxus 1.1.10 → 2.0.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,41 @@ 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
35
 
36
+ /**
37
+ *
38
+ */
39
+ private readonly onRendererMessage = async (event: Electron.MessageEvent): Promise<void> => {
40
+ const { senderId, requestId, path, method, body }: IRequest = event.data;
41
+
42
+ const channels = this.socket.get(senderId);
43
+
44
+ if(!channels) {
45
+ Logger.error(`No message channel found for sender ID: ${senderId}`);
46
+ return;
47
+ }
48
+
49
+ try {
50
+ const request = new Request(event, senderId, requestId, method, path, body);
51
+ const response = await this.router.handle(request);
52
+ channels.request.port1.postMessage(response);
53
+ }
54
+ catch(err: any) {
55
+ const response: IResponse = {
56
+ requestId,
57
+ status: 500,
58
+ body: null,
59
+ error: err.message || 'Internal Server Error',
60
+ };
61
+
62
+ channels.request.port1.postMessage(response);
63
+ }
64
+ };
65
+
36
66
  constructor(
37
67
  private readonly router: Router,
68
+ private readonly socket: NoxSocket,
38
69
  ) {}
39
70
 
40
71
  /**
@@ -62,47 +93,20 @@ export class NoxApp {
62
93
  private giveTheRendererAPort(event: Electron.IpcMainInvokeEvent): void {
63
94
  const senderId = event.sender.id;
64
95
 
65
- if(this.messagePorts.has(senderId)) {
96
+ if(this.socket.get(senderId)) {
66
97
  this.shutdownChannel(senderId);
67
98
  }
68
99
 
69
- const channel = new MessageChannelMain();
70
- this.messagePorts.set(senderId, channel);
71
-
72
- channel.port1.on('message', this.onRendererMessage.bind(this));
73
- channel.port1.start();
100
+ const requestChannel = new MessageChannelMain();
101
+ const socketChannel = new MessageChannelMain();
74
102
 
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;
103
+ requestChannel.port1.on('message', this.onRendererMessage);
104
+ requestChannel.port1.start();
105
+ socketChannel.port1.start();
84
106
 
85
- const channel = this.messagePorts.get(senderId);
107
+ this.socket.register(senderId, requestChannel, socketChannel);
86
108
 
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
- };
103
-
104
- channel.port1.postMessage(response);
105
- }
109
+ event.sender.postMessage('port', { senderId }, [requestChannel.port2, socketChannel.port2]);
106
110
  }
107
111
 
108
112
  /**
@@ -122,18 +126,21 @@ export class NoxApp {
122
126
  * @param remove - Whether to remove the channel from the messagePorts map.
123
127
  */
124
128
  private shutdownChannel(channelSenderId: number): void {
125
- const channel = this.messagePorts.get(channelSenderId);
129
+ const channels = this.socket.get(channelSenderId);
126
130
 
127
- if(!channel) {
131
+ if(!channels) {
128
132
  Logger.warn(`No message channel found for sender ID: ${channelSenderId}`);
129
133
  return;
130
134
  }
131
135
 
132
- channel.port1.off('message', this.onRendererMessage.bind(this));
133
- channel.port1.close();
134
- channel.port2.close();
136
+ channels.request.port1.off('message', this.onRendererMessage);
137
+ channels.request.port1.close();
138
+ channels.request.port2.close();
139
+
140
+ channels.socket.port1.close();
141
+ channels.socket.port2.close();
135
142
 
136
- this.messagePorts.delete(channelSenderId);
143
+ this.socket.unregister(channelSenderId);
137
144
  }
138
145
 
139
146
  /**
@@ -141,11 +148,9 @@ export class NoxApp {
141
148
  * This method is called when all windows are closed, and it cleans up the message channels
142
149
  */
143
150
  private async onAllWindowsClosed(): Promise<void> {
144
- this.messagePorts.forEach((channel, senderId) => {
151
+ for(const senderId of this.socket.getSenderIds()) {
145
152
  this.shutdownChannel(senderId);
146
- });
147
-
148
- this.messagePorts.clear();
153
+ }
149
154
 
150
155
  Logger.info('All windows closed, shutting down application...');
151
156
  await this.app?.dispose();
@@ -6,7 +6,9 @@
6
6
 
7
7
  import { Lifetime } from "src/DI/app-injector";
8
8
  import { InjectorExplorer } from "src/DI/injector-explorer";
9
- import { Type } from "src/utils/types";
9
+ import { defineInjectableMetadata } from "src/decorators/injectable.metadata";
10
+ import { Type } from "src/main";
11
+ export { getInjectableMetadata, hasInjectableMetadata, INJECTABLE_METADATA_KEY } from "src/decorators/injectable.metadata";
10
12
 
11
13
  /**
12
14
  * The Injectable decorator marks a class as injectable.
@@ -15,25 +17,12 @@ import { Type } from "src/utils/types";
15
17
  * either from the constructor of the class that needs it of from the `inject` function.
16
18
  * @param lifetime - The lifetime of the injectable. Can be 'singleton', 'scope', or 'transient'.
17
19
  */
18
- export function Injectable(lifetime: Lifetime = 'scope'): ClassDecorator {
20
+ export function Injectable(lifetime: Lifetime = "scope"): ClassDecorator {
19
21
  return (target) => {
20
- if(typeof target !== 'function' || !target.prototype) {
22
+ if (typeof target !== "function" || !target.prototype) {
21
23
  throw new Error(`@Injectable can only be used on classes, not on ${typeof target}`);
22
24
  }
23
-
24
- Reflect.defineMetadata(INJECTABLE_METADATA_KEY, lifetime, target);
25
+ defineInjectableMetadata(target, lifetime);
25
26
  InjectorExplorer.register(target as unknown as Type<any>, lifetime);
26
27
  };
27
28
  }
28
-
29
- /**
30
- * Gets the injectable metadata for a given target class.
31
- * This metadata includes the lifetime of the injectable defined by the @Injectable decorator.
32
- * @param target - The target class to get the injectable metadata from.
33
- * @returns The lifetime of the injectable if it exists, otherwise undefined.
34
- */
35
- export function getInjectableMetadata(target: Type<unknown>): Lifetime | undefined {
36
- return Reflect.getMetadata(INJECTABLE_METADATA_KEY, target);
37
- }
38
-
39
- export const INJECTABLE_METADATA_KEY = Symbol('INJECTABLE_METADATA_KEY');
@@ -0,0 +1,15 @@
1
+ import type { Lifetime } from "src/DI/app-injector";
2
+
3
+ export const INJECTABLE_METADATA_KEY = Symbol("INJECTABLE_METADATA_KEY");
4
+
5
+ export function defineInjectableMetadata(target: Function, lifetime: Lifetime): void {
6
+ Reflect.defineMetadata(INJECTABLE_METADATA_KEY, lifetime, target);
7
+ }
8
+
9
+ export function getInjectableMetadata(target: Function): Lifetime | undefined {
10
+ return Reflect.getMetadata(INJECTABLE_METADATA_KEY, target) as Lifetime | undefined;
11
+ }
12
+
13
+ export function hasInjectableMetadata(target: Function): boolean {
14
+ return Reflect.hasMetadata(INJECTABLE_METADATA_KEY, target);
15
+ }
package/src/index.ts CHANGED
@@ -4,17 +4,6 @@
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';
9
+ export * from './renderer-client';
package/src/main.ts ADDED
@@ -0,0 +1,28 @@
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 './preload-bridge';
22
+ export * from './utils/logger';
23
+ export * from './utils/types';
24
+ export * from './request';
25
+ export * from './renderer-events';
26
+ export * from './renderer-client';
27
+ export * from './socket';
28
+
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @copyright 2025 NoxFly
3
+ * @license MIT
4
+ * @author NoxFly
5
+ */
6
+
7
+ import { contextBridge, ipcRenderer } from 'electron/renderer';
8
+ import type { IPortRequester } from 'src/renderer-client';
9
+
10
+ export interface NoxusPreloadAPI extends IPortRequester {}
11
+
12
+ export interface NoxusPreloadOptions {
13
+ exposeAs?: string;
14
+ initMessageType?: string;
15
+ requestChannel?: string;
16
+ responseChannel?: string;
17
+ targetWindow?: Window;
18
+ }
19
+
20
+ const DEFAULT_EXPOSE_NAME = 'noxus';
21
+ const DEFAULT_INIT_EVENT = 'init-port';
22
+ const DEFAULT_REQUEST_CHANNEL = 'gimme-my-port';
23
+ const DEFAULT_RESPONSE_CHANNEL = 'port';
24
+
25
+ /**
26
+ * Exposes a minimal bridge in the isolated preload context so renderer processes
27
+ * can request the two MessagePorts required by Noxus. The bridge forwards both
28
+ * request/response and socket ports to the renderer via window.postMessage.
29
+ */
30
+ export function exposeNoxusBridge(options: NoxusPreloadOptions = {}): NoxusPreloadAPI {
31
+ const {
32
+ exposeAs = DEFAULT_EXPOSE_NAME,
33
+ initMessageType = DEFAULT_INIT_EVENT,
34
+ requestChannel = DEFAULT_REQUEST_CHANNEL,
35
+ responseChannel = DEFAULT_RESPONSE_CHANNEL,
36
+ targetWindow = window,
37
+ } = options;
38
+
39
+ const api: NoxusPreloadAPI = {
40
+ requestPort: () => {
41
+ ipcRenderer.send(requestChannel);
42
+
43
+ ipcRenderer.once(responseChannel, (event, message: { senderId: number }) => {
44
+ const ports = (event.ports ?? []).filter((port): port is MessagePort => port !== undefined);
45
+
46
+ if(ports.length === 0) {
47
+ console.error('[Noxus] No MessagePort received from main process.');
48
+ return;
49
+ }
50
+
51
+ for(const port of ports) {
52
+ try {
53
+ port.start();
54
+ }
55
+ catch(error) {
56
+ console.error('[Noxus] Failed to start MessagePort.', error);
57
+ }
58
+ }
59
+
60
+ targetWindow.postMessage(
61
+ {
62
+ type: initMessageType,
63
+ senderId: message?.senderId,
64
+ },
65
+ '*',
66
+ ports,
67
+ );
68
+ });
69
+ },
70
+ };
71
+
72
+ contextBridge.exposeInMainWorld(exposeAs, api);
73
+
74
+ return api;
75
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * @copyright 2025 NoxFly
3
+ * @license MIT
4
+ * @author NoxFly
5
+ */
6
+
7
+ import { IBatchRequestItem, IBatchResponsePayload, IRequest, IResponse } from 'src/request';
8
+ import { RendererEventRegistry } from 'src/renderer-events';
9
+
10
+ export interface IPortRequester {
11
+ requestPort(): void;
12
+ }
13
+
14
+ export interface RendererClientOptions {
15
+ bridge?: IPortRequester | null;
16
+ bridgeName?: string | string[];
17
+ initMessageType?: string;
18
+ windowRef?: Window;
19
+ generateRequestId?: () => string;
20
+ }
21
+
22
+ interface PendingRequest<T = unknown> {
23
+ resolve: (value: T) => void;
24
+ reject: (reason: IResponse<T>) => void;
25
+ request: IRequest;
26
+ submittedAt: number;
27
+ }
28
+
29
+ const DEFAULT_INIT_EVENT = 'init-port';
30
+ const DEFAULT_BRIDGE_NAMES = ['noxus', 'ipcRenderer'];
31
+
32
+ function defaultRequestId(): string {
33
+ if(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
34
+ return crypto.randomUUID();
35
+ }
36
+
37
+ return `${Date.now().toString(16)}-${Math.floor(Math.random() * 1e8).toString(16)}`;
38
+ }
39
+
40
+ function normalizeBridgeNames(preferred?: string | string[]): string[] {
41
+ const names: string[] = [];
42
+
43
+ const add = (name: string | undefined): void => {
44
+ if(!name)
45
+ return;
46
+
47
+ if(!names.includes(name)) {
48
+ names.push(name);
49
+ }
50
+ };
51
+
52
+ if(Array.isArray(preferred)) {
53
+ for(const name of preferred) {
54
+ add(name);
55
+ }
56
+ }
57
+ else {
58
+ add(preferred);
59
+ }
60
+
61
+ for(const fallback of DEFAULT_BRIDGE_NAMES) {
62
+ add(fallback);
63
+ }
64
+
65
+ return names;
66
+ }
67
+
68
+ function resolveBridgeFromWindow(windowRef: Window, preferred?: string | string[]): IPortRequester | null {
69
+ const names = normalizeBridgeNames(preferred);
70
+ const globalRef = windowRef as unknown as Record<string, unknown> | null | undefined;
71
+
72
+ if(!globalRef) {
73
+ return null;
74
+ }
75
+
76
+ for(const name of names) {
77
+ const candidate = globalRef[name];
78
+
79
+ if(candidate && typeof (candidate as IPortRequester).requestPort === 'function') {
80
+ return candidate as IPortRequester;
81
+ }
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ export class NoxRendererClient {
88
+ public readonly events = new RendererEventRegistry();
89
+
90
+ protected readonly pendingRequests = new Map<string, PendingRequest>();
91
+
92
+ protected requestPort: MessagePort | undefined;
93
+ protected socketPort: MessagePort | undefined;
94
+ protected senderId: number | undefined;
95
+
96
+ private readonly bridge: IPortRequester | null;
97
+ private readonly initMessageType: string;
98
+ private readonly windowRef: Window;
99
+ private readonly generateRequestId: () => string;
100
+
101
+ private isReady = false;
102
+ private setupPromise: Promise<void> | undefined;
103
+ private setupResolve: (() => void) | undefined;
104
+ private setupReject: ((reason: Error) => void) | undefined;
105
+
106
+ constructor(options: RendererClientOptions = {}) {
107
+ this.windowRef = options.windowRef ?? window;
108
+ const resolvedBridge = options.bridge ?? resolveBridgeFromWindow(this.windowRef, options.bridgeName);
109
+ this.bridge = resolvedBridge ?? null;
110
+ this.initMessageType = options.initMessageType ?? DEFAULT_INIT_EVENT;
111
+ this.generateRequestId = options.generateRequestId ?? defaultRequestId;
112
+ }
113
+
114
+ public async setup(): Promise<void> {
115
+ if(this.isReady) {
116
+ return Promise.resolve();
117
+ }
118
+
119
+ if(this.setupPromise) {
120
+ return this.setupPromise;
121
+ }
122
+
123
+ if(!this.bridge || typeof this.bridge.requestPort !== 'function') {
124
+ throw new Error('[Noxus] Renderer bridge is missing requestPort().');
125
+ }
126
+
127
+ this.setupPromise = new Promise<void>((resolve, reject) => {
128
+ this.setupResolve = resolve;
129
+ this.setupReject = reject;
130
+ });
131
+
132
+ this.windowRef.addEventListener('message', this.onWindowMessage);
133
+ this.bridge.requestPort();
134
+
135
+ return this.setupPromise;
136
+ }
137
+
138
+ public dispose(): void {
139
+ this.windowRef.removeEventListener('message', this.onWindowMessage);
140
+
141
+ this.requestPort?.close();
142
+ this.socketPort?.close();
143
+
144
+ this.requestPort = undefined;
145
+ this.socketPort = undefined;
146
+ this.senderId = undefined;
147
+ this.isReady = false;
148
+
149
+ this.pendingRequests.clear();
150
+ }
151
+
152
+ public async request<TResponse, TBody = unknown>(request: Omit<IRequest<TBody>, 'requestId' | 'senderId'>): Promise<TResponse> {
153
+ const senderId = this.senderId;
154
+ const requestId = this.generateRequestId();
155
+
156
+ if(senderId === undefined) {
157
+ return Promise.reject(this.createErrorResponse(requestId, 'MessagePort is not available'));
158
+ }
159
+
160
+ const readinessError = this.validateReady(requestId);
161
+
162
+ if(readinessError) {
163
+ return Promise.reject(readinessError as IResponse<TResponse>);
164
+ }
165
+
166
+ const message: IRequest<TBody> = {
167
+ requestId,
168
+ senderId,
169
+ ...request,
170
+ };
171
+
172
+ return new Promise<TResponse>((resolve, reject) => {
173
+ const pending: PendingRequest<TResponse> = {
174
+ resolve,
175
+ reject: (response: IResponse<TResponse>) => reject(response),
176
+ request: message,
177
+ submittedAt: Date.now(),
178
+ };
179
+
180
+ this.pendingRequests.set(message.requestId, pending as PendingRequest);
181
+
182
+ this.requestPort!.postMessage(message);
183
+ });
184
+ }
185
+
186
+ public async batch(requests: Omit<IBatchRequestItem<unknown>, 'requestId'>[]): Promise<IBatchResponsePayload> {
187
+ return this.request<IBatchResponsePayload>({
188
+ method: 'BATCH',
189
+ path: '',
190
+ body: {
191
+ requests,
192
+ },
193
+ });
194
+ }
195
+
196
+ public getSenderId(): number | undefined {
197
+ return this.senderId;
198
+ }
199
+
200
+ private readonly onWindowMessage = (event: MessageEvent): void => {
201
+ if(event.data?.type !== this.initMessageType) {
202
+ return;
203
+ }
204
+
205
+ if(!Array.isArray(event.ports) || event.ports.length < 2) {
206
+ const error = new Error('[Noxus] Renderer expected two MessagePorts (request + socket).');
207
+
208
+ console.error(error);
209
+ this.setupReject?.(error);
210
+ this.resetSetupState();
211
+ return;
212
+ }
213
+
214
+ this.windowRef.removeEventListener('message', this.onWindowMessage);
215
+
216
+ this.requestPort = event.ports[0];
217
+ this.socketPort = event.ports[1];
218
+ this.senderId = event.data.senderId;
219
+
220
+ if(this.requestPort === undefined || this.socketPort === undefined) {
221
+ const error = new Error('[Noxus] Renderer failed to receive valid MessagePorts.');
222
+ console.error(error);
223
+ this.setupReject?.(error);
224
+ this.resetSetupState();
225
+ return;
226
+ }
227
+
228
+ this.attachRequestPort(this.requestPort);
229
+ this.attachSocketPort(this.socketPort);
230
+
231
+ this.isReady = true;
232
+ this.setupResolve?.();
233
+ this.resetSetupState(true);
234
+ };
235
+
236
+ private readonly onSocketMessage = (event: MessageEvent): void => {
237
+ if(this.events.tryDispatchFromMessageEvent(event)) {
238
+ return;
239
+ }
240
+
241
+ console.warn('[Noxus] Received a socket message that is not a renderer event payload.', event.data);
242
+ };
243
+
244
+ private readonly onRequestMessage = (event: MessageEvent): void => {
245
+ if(this.events.tryDispatchFromMessageEvent(event)) {
246
+ return;
247
+ }
248
+
249
+ const response: IResponse = event.data;
250
+
251
+ if(!response || typeof response.requestId !== 'string') {
252
+ console.error('[Noxus] Renderer received an invalid response payload.', response);
253
+ return;
254
+ }
255
+
256
+ const pending = this.pendingRequests.get(response.requestId);
257
+
258
+ if(!pending) {
259
+ console.error(`[Noxus] No pending handler found for request ${response.requestId}.`);
260
+ return;
261
+ }
262
+
263
+ this.pendingRequests.delete(response.requestId);
264
+
265
+ this.onRequestCompleted(pending, response);
266
+
267
+ if(response.status >= 400) {
268
+ pending.reject(response as IResponse<any>);
269
+ return;
270
+ }
271
+
272
+ pending.resolve(response.body as unknown);
273
+ };
274
+
275
+ protected onRequestCompleted(pending: PendingRequest, response: IResponse): void {
276
+ if(typeof console.groupCollapsed === 'function') {
277
+ console.groupCollapsed(`${response.status} ${pending.request.method} /${pending.request.path}`);
278
+ }
279
+
280
+ if(response.error) {
281
+ console.error('error message:', response.error);
282
+ }
283
+
284
+ if(response.body !== undefined) {
285
+ console.info('response:', response.body);
286
+ }
287
+
288
+ console.info('request:', pending.request);
289
+ console.info(`Request duration: ${Date.now() - pending.submittedAt} ms`);
290
+
291
+ if(typeof console.groupCollapsed === 'function') {
292
+ console.groupEnd();
293
+ }
294
+ }
295
+
296
+ private attachRequestPort(port: MessagePort): void {
297
+ port.onmessage = this.onRequestMessage;
298
+ port.start();
299
+ }
300
+
301
+ private attachSocketPort(port: MessagePort): void {
302
+ port.onmessage = this.onSocketMessage;
303
+ port.start();
304
+ }
305
+
306
+ private validateReady(requestId: string): IResponse | undefined {
307
+ if(!this.isElectronEnvironment()) {
308
+ return this.createErrorResponse(requestId, 'Not running in Electron environment');
309
+ }
310
+
311
+ if(!this.requestPort) {
312
+ return this.createErrorResponse(requestId, 'MessagePort is not available');
313
+ }
314
+
315
+ return undefined;
316
+ }
317
+
318
+ private createErrorResponse<T>(requestId: string, message: string): IResponse<T> {
319
+ return {
320
+ status: 500,
321
+ requestId,
322
+ error: message,
323
+ };
324
+ }
325
+
326
+ private resetSetupState(success = false): void {
327
+ if(!success) {
328
+ this.setupPromise = undefined;
329
+ }
330
+
331
+ this.setupResolve = undefined;
332
+ this.setupReject = undefined;
333
+ }
334
+
335
+ public isElectronEnvironment(): boolean {
336
+ return typeof window !== 'undefined' && /Electron/.test(window.navigator.userAgent);
337
+ }
338
+ }