@fivem-nui/core 1.0.0 → 1.1.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.
@@ -1,88 +1,95 @@
1
- import { NuiErrorCodes, NuiRequestError } from './errors';
2
- import type { NuiLogger } from './logger';
3
- import type { NuiMessage } from './types';
4
-
5
- export type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
6
-
7
- /**
8
- * HTTP transport for FiveM NUI.
9
- *
10
- * Posts envelopes to `https://<resource>/<callback>` using `fetch`.
11
- * Each POST is bounded by an `AbortController` deadline so a resource
12
- * that stops answering (restart, shutdown) does not hang the page forever.
13
- *
14
- * Mirrors C# `FiveMNuiTransport`.
15
- */
16
- export class MessageTransport {
17
- private readonly resource: string;
18
- private readonly callbackName: string;
19
- private readonly postTimeoutMs: number;
20
- private readonly logger: NuiLogger;
21
- private readonly fetchImpl: FetchImpl;
22
-
23
- constructor(
24
- resource: string,
25
- callbackName: string,
26
- postTimeoutMs: number,
27
- logger: NuiLogger,
28
- fetchImpl: FetchImpl,
29
- ) {
30
- this.resource = resource;
31
- this.callbackName = callbackName;
32
- this.postTimeoutMs = postTimeoutMs;
33
- this.logger = logger;
34
- this.fetchImpl = fetchImpl;
35
- }
36
-
37
- /** URL of the main resource callback. */
38
- get mainCallbackUrl(): string {
39
- return `https://${this.resource}/${encodeURIComponent(this.callbackName)}`;
40
- }
41
-
42
- /** Builds the URL for an arbitrary callback on the same resource. */
43
- callbackUrl(callbackName: string): string {
44
- return `https://${this.resource}/${encodeURIComponent(callbackName)}`;
45
- }
46
-
47
- /**
48
- * Raw POST to `url` with the configured timeout deadline.
49
- *
50
- * @throws {NuiRequestError} with code `POST_TIMEOUT` when the resource
51
- * stops answering within `postTimeoutMs`.
52
- * @throws {NuiRequestError} with code `TRANSPORT` for any other failure.
53
- */
54
- async post(url: string, body: unknown): Promise<void> {
55
- // Without an abort, a resource that stops answering leaves this promise
56
- // pending forever and the caller's await never returns.
57
- const controller = new AbortController();
58
- const deadline = setTimeout(() => controller.abort(), this.postTimeoutMs);
59
-
60
- try {
61
- await this.fetchImpl(url, {
62
- method: 'POST',
63
- headers: { 'Content-Type': 'application/json; charset=UTF-8' },
64
- body: JSON.stringify(body),
65
- signal: controller.signal,
66
- });
67
- } catch (error) {
68
- // A timeout abort surfaces as an error whose `name` is 'AbortError'.
69
- // In browsers that is a DOMException, but some fetch polyfills throw a
70
- // plain Error, so match on the name instead of the constructor. Checking
71
- // only `controller.signal.aborted` is unsafe: a race between the deadline
72
- // firing and an unrelated network error would misclassify the network
73
- // error as POST_TIMEOUT.
74
- const isAbortError = (error as { name?: string } | null)?.name === 'AbortError';
75
-
76
- if (isAbortError) {
77
- throw new NuiRequestError(
78
- NuiErrorCodes.PostTimeout,
79
- `POST ${url} timed out after ${this.postTimeoutMs}ms`,
80
- );
81
- }
82
-
83
- throw new NuiRequestError(NuiErrorCodes.Transport, String(error));
84
- } finally {
85
- clearTimeout(deadline);
86
- }
87
- }
88
- }
1
+ import { NuiErrorCodes, NuiRequestError } from './errors';
2
+ import type { NuiLogger } from './logger';
3
+ import type { NuiMessage } from './types';
4
+
5
+ export type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
6
+
7
+ /**
8
+ * HTTP transport for FiveM NUI.
9
+ *
10
+ * Posts envelopes to `https://<resource>/<callback>` using `fetch`.
11
+ * Each POST is bounded by an `AbortController` deadline so a resource
12
+ * that stops answering (restart, shutdown) does not hang the page forever.
13
+ *
14
+ * Mirrors C# `FiveMNuiTransport`.
15
+ */
16
+ export class MessageTransport {
17
+ private readonly resource: string;
18
+ private readonly callbackName: string;
19
+ private readonly postTimeoutMs: number;
20
+ private readonly logger: NuiLogger;
21
+ private readonly fetchImpl: FetchImpl;
22
+
23
+ constructor(
24
+ resource: string,
25
+ callbackName: string,
26
+ postTimeoutMs: number,
27
+ logger: NuiLogger,
28
+ fetchImpl: FetchImpl,
29
+ ) {
30
+ this.resource = resource;
31
+ this.callbackName = callbackName;
32
+ this.postTimeoutMs = postTimeoutMs;
33
+ this.logger = logger;
34
+ this.fetchImpl = fetchImpl;
35
+ }
36
+
37
+ /** URL of the main resource callback. */
38
+ get mainCallbackUrl(): string {
39
+ return `https://${this.resource}/${encodeURIComponent(this.callbackName)}`;
40
+ }
41
+
42
+ /** Builds the URL for an arbitrary callback on the same resource. */
43
+ callbackUrl(callbackName: string): string {
44
+ return `https://${this.resource}/${encodeURIComponent(callbackName)}`;
45
+ }
46
+
47
+ /**
48
+ * Raw POST to `url` with the configured timeout deadline.
49
+ *
50
+ * @throws {NuiRequestError} with code `POST_TIMEOUT` when the resource
51
+ * stops answering within `postTimeoutMs`.
52
+ * @throws {NuiRequestError} with code `TRANSPORT` for any other failure.
53
+ */
54
+ async post(url: string, body: unknown): Promise<void> {
55
+ // Without an abort, a resource that stops answering leaves this promise
56
+ // pending forever and the caller's await never returns.
57
+ const controller = new AbortController();
58
+ const deadline = setTimeout(() => controller.abort(), this.postTimeoutMs);
59
+
60
+ try {
61
+ const response = await this.fetchImpl(url, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json; charset=UTF-8' },
64
+ body: JSON.stringify(body),
65
+ signal: controller.signal,
66
+ });
67
+ if (!response.ok) {
68
+ throw new NuiRequestError(
69
+ NuiErrorCodes.Transport,
70
+ `POST ${url} failed with HTTP ${response.status}`,
71
+ );
72
+ }
73
+ } catch (error) {
74
+ // A timeout abort surfaces as an error whose `name` is 'AbortError'.
75
+ // In browsers that is a DOMException, but some fetch polyfills throw a
76
+ // plain Error, so match on the name instead of the constructor. Checking
77
+ // only `controller.signal.aborted` is unsafe: a race between the deadline
78
+ // firing and an unrelated network error would misclassify the network
79
+ // error as POST_TIMEOUT.
80
+ const isAbortError = (error as { name?: string } | null)?.name === 'AbortError';
81
+
82
+ if (isAbortError) {
83
+ throw new NuiRequestError(
84
+ NuiErrorCodes.PostTimeout,
85
+ `POST ${url} timed out after ${this.postTimeoutMs}ms`,
86
+ );
87
+ }
88
+
89
+ if (error instanceof NuiRequestError) throw error;
90
+ throw new NuiRequestError(NuiErrorCodes.Transport, String(error));
91
+ } finally {
92
+ clearTimeout(deadline);
93
+ }
94
+ }
95
+ }
package/src/NuiClient.ts CHANGED
@@ -35,15 +35,15 @@ const WINDOW_ACTIONS: ReadonlySet<string> = new Set<NuiWindowAction>([
35
35
  type WindowHandler = (action: NuiWindowAction, window: string | undefined) => void;
36
36
 
37
37
  /**
38
- * `NuiClient` is **thin façade** it composes four collaborators and
38
+ * `NuiClient` is **thin façade** it composes four collaborators and
39
39
  * orchestrates their interaction:
40
40
  *
41
41
  * | Collaborator | Responsibility |
42
42
  * |------------------------|----------------------------------------|
43
43
  * | `MessageTransport` | Raw HTTP POST to `https://<res>/<cb>` |
44
- * | `RequestCoordinator` | Correlation ID ↔ Promise resolution |
44
+ * | `RequestCoordinator` | Correlation ID ↔ Promise resolution |
45
45
  * | `CallbackRegistry` | C#-request handler lookup |
46
- * | `EventBus` | C#→UI event subscription / dispatch |
46
+ * | `EventBus` | C#→UI event subscription / dispatch |
47
47
  *
48
48
  * Window lifecycle listeners (`onWindow`) and the browser `message`
49
49
  * listener (`listen`) stay here because they are browser-integration glue.
@@ -94,7 +94,7 @@ export class NuiClient {
94
94
 
95
95
  this.logger.debug('NuiClient initialized', { resource, callbackName, postTimeoutMs });
96
96
  }
97
-
97
+
98
98
  /**
99
99
  * Sends a one way message to C#.
100
100
  *
@@ -277,7 +277,7 @@ export class NuiClient {
277
277
  throw error;
278
278
  }
279
279
  }
280
-
280
+
281
281
  // Callback server (C# → UI requests)
282
282
  /**
283
283
  * Registers a callback handler for C# requests.
@@ -339,7 +339,7 @@ export class NuiClient {
339
339
  unregisterCallback(eventName: string): boolean {
340
340
  return this.callbackRegistry.unregister(eventName);
341
341
  }
342
-
342
+
343
343
  /**
344
344
  * Subscribes to C# events.
345
345
  *
@@ -349,7 +349,7 @@ export class NuiClient {
349
349
  on<T>(eventName: keyof NuiEventMap extends never ? string : never, handler: (data: T) => void): () => void;
350
350
  on(eventName: string, handler: (data: unknown) => void): () => void {
351
351
  if (this.disposed) {
352
- return () => {};
352
+ return () => { };
353
353
  }
354
354
 
355
355
  return this.eventBus.on(eventName, handler);
@@ -362,7 +362,7 @@ export class NuiClient {
362
362
  once<T>(eventName: keyof NuiEventMap extends never ? string : never, handler: (data: T) => void): () => void;
363
363
  once(eventName: string, handler: (data: unknown) => void): () => void {
364
364
  if (this.disposed) {
365
- return () => {};
365
+ return () => { };
366
366
  }
367
367
 
368
368
  return this.eventBus.once(eventName, handler);
@@ -374,7 +374,7 @@ export class NuiClient {
374
374
  off(eventName: string, handler?: EventHandler): void {
375
375
  this.eventBus.off(eventName, handler);
376
376
  }
377
-
377
+
378
378
  /**
379
379
  * Subscribes to window lifecycle envelopes from C#.
380
380
  *
@@ -383,7 +383,7 @@ export class NuiClient {
383
383
  */
384
384
  onWindow(handler: WindowHandler): () => void {
385
385
  if (this.disposed) {
386
- return () => {};
386
+ return () => { };
387
387
  }
388
388
 
389
389
  this.windowListeners.add(handler);
@@ -392,7 +392,7 @@ export class NuiClient {
392
392
  this.windowListeners.delete(handler);
393
393
  };
394
394
  }
395
-
395
+
396
396
  /**
397
397
  * Routes an incoming message from C#. Pass `MessageEvent.data` here;
398
398
  * `listen()` does that for you.
@@ -430,7 +430,7 @@ export class NuiClient {
430
430
  */
431
431
  listen(target: Window = window): () => void {
432
432
  if (this.listening) {
433
- return () => {};
433
+ return () => { };
434
434
  }
435
435
 
436
436
  this.listening = true;
@@ -451,15 +451,19 @@ export class NuiClient {
451
451
 
452
452
  target.addEventListener('message', listener as EventListener);
453
453
 
454
- this.stopListening = () => {
454
+ let stopped = false;
455
+ const stop = () => {
456
+ if (stopped) return;
457
+ stopped = true;
455
458
  target.removeEventListener('message', listener as EventListener);
456
- this.listening = false;
457
- this.stopListening = null;
459
+ if (this.stopListening === stop) {
460
+ this.listening = false;
461
+ this.stopListening = null;
462
+ }
458
463
  };
464
+ this.stopListening = stop;
459
465
 
460
- return () => {
461
- this.stopListening?.();
462
- };
466
+ return stop;
463
467
  }
464
468
 
465
469
  /** Handles incoming requests from C#. */
@@ -556,7 +560,7 @@ export class NuiClient {
556
560
  }
557
561
  }
558
562
  }
559
-
563
+
560
564
  /**
561
565
  * POSTs an envelope to the main callback, incrementing the `sent` counter.
562
566
  * Reused by `send`, `sendAndWait` and `request`.
@@ -575,7 +579,7 @@ export class NuiClient {
575
579
  this.logger.warn('Could not deliver response', error);
576
580
  }
577
581
  }
578
-
582
+
579
583
  /** Current counters. Cheap enough to call from a diagnostics panel. */
580
584
  getDiagnostics(): NuiDiagnostics {
581
585
  return {
@@ -1,57 +1,57 @@
1
- import type { NuiLogger } from './logger';
2
-
3
- /**
4
- * Options for the NUI bridge.
5
- */
6
- export interface NuiClientOptions {
7
- /**
8
- * Resource name the NUI page is served from. Defaults to `GetParentResourceName()`.
9
- */
10
- resource?: string;
11
- /**
12
- * Name of the resource callback C# registered via `RegisterRawNuiCallback`.
13
- * Every outbound message is POSTed to `https://<resource>/<callback>`.
14
- */
15
- callback?: string;
16
- /**
17
- * Window name that `open`/`close` envelopes refer to. Passed straight
18
- * through to `onWindow`, so the UI can ignore other windows.
19
- */
20
- window?: string;
21
- /**
22
- * How long a single POST may take before it is aborted, in milliseconds.
23
- * The resource can stop answering at any time (restart, shutdown) and a raw
24
- * fetch without a deadline would leave the caller awaiting forever.
25
- */
26
- postTimeoutMs?: number;
27
- /**
28
- * Default deadline for {@link NuiClient.request}, in milliseconds.
29
- * Matches the C# `NuiOptions.DefaultTimeout` default of 30s.
30
- */
31
- requestTimeoutMs?: number;
32
- /**
33
- * Where diagnostics go. Defaults to the console; pass `nullNuiLogger` to
34
- * silence it entirely.
35
- */
36
- logger?: NuiLogger;
37
- /**
38
- * Injection seam for tests. Defaults to the global `fetch`.
39
- */
40
- fetch?: typeof fetch;
41
- }
42
-
43
- /**
44
- * Resolves the owning FiveM resource name from the ambient FiveM runtime.
45
- *
46
- * Falls back to `'unknown_resource'` when running outside a game context
47
- * (SSR, unit tests, build pipeline) so importing the package never throws.
48
- */
49
- export function readResourceName(): string {
50
- const runtime = globalThis as { GetParentResourceName?: () => string };
51
-
52
- if (typeof runtime.GetParentResourceName === 'function') {
53
- return runtime.GetParentResourceName();
54
- }
55
-
56
- return 'unknown_resource';
57
- }
1
+ import type { NuiLogger } from './logger';
2
+
3
+ /**
4
+ * Options for the NUI bridge.
5
+ */
6
+ export interface NuiClientOptions {
7
+ /**
8
+ * Resource name the NUI page is served from. Defaults to `GetParentResourceName()`.
9
+ */
10
+ resource?: string;
11
+ /**
12
+ * Name of the resource callback C# registered via `RegisterRawNuiCallback`.
13
+ * Every outbound message is POSTed to `https://<resource>/<callback>`.
14
+ */
15
+ callback?: string;
16
+ /**
17
+ * Window name that `open`/`close` envelopes refer to. Passed straight
18
+ * through to `onWindow`, so the UI can ignore other windows.
19
+ */
20
+ window?: string;
21
+ /**
22
+ * How long a single POST may take before it is aborted, in milliseconds.
23
+ * The resource can stop answering at any time (restart, shutdown) and a raw
24
+ * fetch without a deadline would leave the caller awaiting forever.
25
+ */
26
+ postTimeoutMs?: number;
27
+ /**
28
+ * Default deadline for {@link NuiClient.request}, in milliseconds.
29
+ * Matches the C# `NuiOptions.DefaultTimeout` default of 30s.
30
+ */
31
+ requestTimeoutMs?: number;
32
+ /**
33
+ * Where diagnostics go. Defaults to the console; pass `nullNuiLogger` to
34
+ * silence it entirely.
35
+ */
36
+ logger?: NuiLogger;
37
+ /**
38
+ * Injection seam for tests. Defaults to the global `fetch`.
39
+ */
40
+ fetch?: typeof fetch;
41
+ }
42
+
43
+ /**
44
+ * Resolves the owning FiveM resource name from the ambient FiveM runtime.
45
+ *
46
+ * Falls back to `'unknown_resource'` when running outside a game context
47
+ * (SSR, unit tests, build pipeline) so importing the package never throws.
48
+ */
49
+ export function readResourceName(): string {
50
+ const runtime = globalThis as { GetParentResourceName?: () => string };
51
+
52
+ if (typeof runtime.GetParentResourceName === 'function') {
53
+ return runtime.GetParentResourceName();
54
+ }
55
+
56
+ return 'unknown_resource';
57
+ }
@@ -1,105 +1,105 @@
1
- /**
2
- * A pending request awaiting a correlated response.
3
- */
4
- interface PendingRequest {
5
- resolve: (value: unknown) => void;
6
- reject: (error: Error) => void;
7
- timeout: ReturnType<typeof setTimeout>,
8
- /** Released whenever the request settles or is removed (e.g. abort listener). */
9
- cleanup?: () => void;
10
- }
11
-
12
- /**
13
- * Tracks in-flight requests by correlation ID, matching responses
14
- * to their Promise resolvers.
15
- *
16
- * Mirrors C# `ConcurrentRequestManager` same idea, same responsibilities:
17
- * register, resolve, reject, unregister, and reject all on dispose.
18
- */
19
- export class RequestCoordinator {
20
- private pending = new Map<string, PendingRequest>();
21
-
22
- /** Store a pending request so a later response can resolve or reject it. */
23
- register(
24
- id: string,
25
- resolve: PendingRequest['resolve'],
26
- reject: PendingRequest['reject'],
27
- timeout: ReturnType<typeof setTimeout>,
28
- cleanup?: () => void,
29
- ): void {
30
- this.pending.set(id, { resolve, reject, timeout, cleanup });
31
- }
32
-
33
- /**
34
- * Resolve the request whose correlation ID matches.
35
- * @returns `true` if a matching request was found.
36
- */
37
- resolve(id: string, payload: unknown): boolean {
38
- const entry = this.pending.get(id);
39
- if (!entry) {
40
- return false;
41
- }
42
-
43
- clearTimeout(entry.timeout);
44
- entry.cleanup?.();
45
- this.pending.delete(id);
46
- entry.resolve(payload);
47
- return true;
48
- }
49
-
50
- /**
51
- * Reject the request whose correlation ID matches.
52
- * @returns `true` if a matching request was found.
53
- */
54
- reject(id: string, error: Error): boolean {
55
- const entry = this.pending.get(id);
56
- if (!entry) {
57
- return false;
58
- }
59
-
60
- clearTimeout(entry.timeout);
61
- entry.cleanup?.();
62
- this.pending.delete(id);
63
- entry.reject(error);
64
- return true;
65
- }
66
-
67
- /** Removes a request without resolving or rejecting it (error-path cleanup). */
68
- unregister(id: string): void {
69
- const entry = this.pending.get(id);
70
- if (entry) {
71
- clearTimeout(entry.timeout);
72
- entry.cleanup?.();
73
- this.pending.delete(id);
74
- }
75
- }
76
-
77
- /** Number of requests still awaiting a response. */
78
- get pendingCount(): number {
79
- return this.pending.size;
80
- }
81
-
82
- /**
83
- * Rejects every outstanding request with the supplied error.
84
- * Called during `dispose()` so callers don't hang forever.
85
- * @returns the number of requests that were rejected.
86
- */
87
- rejectAll(error: Error): number {
88
- const entries = Array.from(this.pending.values());
89
-
90
- this.pending.clear();
91
-
92
- for (const entry of entries) {
93
- clearTimeout(entry.timeout);
94
- entry.cleanup?.();
95
- entry.reject(error);
96
- }
97
-
98
- return entries.length;
99
- }
100
-
101
- /** Generates a unique correlation ID for a new request. */
102
- static generateCorrelationId(): string {
103
- return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
104
- }
105
- }
1
+ /**
2
+ * A pending request awaiting a correlated response.
3
+ */
4
+ interface PendingRequest {
5
+ resolve: (value: unknown) => void;
6
+ reject: (error: Error) => void;
7
+ timeout: ReturnType<typeof setTimeout>,
8
+ /** Released whenever the request settles or is removed (e.g. abort listener). */
9
+ cleanup?: () => void;
10
+ }
11
+
12
+ /**
13
+ * Tracks in-flight requests by correlation ID, matching responses
14
+ * to their Promise resolvers.
15
+ *
16
+ * Mirrors C# `ConcurrentRequestManager` same idea, same responsibilities:
17
+ * register, resolve, reject, unregister, and reject all on dispose.
18
+ */
19
+ export class RequestCoordinator {
20
+ private pending = new Map<string, PendingRequest>();
21
+
22
+ /** Store a pending request so a later response can resolve or reject it. */
23
+ register(
24
+ id: string,
25
+ resolve: PendingRequest['resolve'],
26
+ reject: PendingRequest['reject'],
27
+ timeout: ReturnType<typeof setTimeout>,
28
+ cleanup?: () => void,
29
+ ): void {
30
+ this.pending.set(id, { resolve, reject, timeout, cleanup });
31
+ }
32
+
33
+ /**
34
+ * Resolve the request whose correlation ID matches.
35
+ * @returns `true` if a matching request was found.
36
+ */
37
+ resolve(id: string, payload: unknown): boolean {
38
+ const entry = this.pending.get(id);
39
+ if (!entry) {
40
+ return false;
41
+ }
42
+
43
+ clearTimeout(entry.timeout);
44
+ entry.cleanup?.();
45
+ this.pending.delete(id);
46
+ entry.resolve(payload);
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Reject the request whose correlation ID matches.
52
+ * @returns `true` if a matching request was found.
53
+ */
54
+ reject(id: string, error: Error): boolean {
55
+ const entry = this.pending.get(id);
56
+ if (!entry) {
57
+ return false;
58
+ }
59
+
60
+ clearTimeout(entry.timeout);
61
+ entry.cleanup?.();
62
+ this.pending.delete(id);
63
+ entry.reject(error);
64
+ return true;
65
+ }
66
+
67
+ /** Removes a request without resolving or rejecting it (error-path cleanup). */
68
+ unregister(id: string): void {
69
+ const entry = this.pending.get(id);
70
+ if (entry) {
71
+ clearTimeout(entry.timeout);
72
+ entry.cleanup?.();
73
+ this.pending.delete(id);
74
+ }
75
+ }
76
+
77
+ /** Number of requests still awaiting a response. */
78
+ get pendingCount(): number {
79
+ return this.pending.size;
80
+ }
81
+
82
+ /**
83
+ * Rejects every outstanding request with the supplied error.
84
+ * Called during `dispose()` so callers don't hang forever.
85
+ * @returns the number of requests that were rejected.
86
+ */
87
+ rejectAll(error: Error): number {
88
+ const entries = Array.from(this.pending.values());
89
+
90
+ this.pending.clear();
91
+
92
+ for (const entry of entries) {
93
+ clearTimeout(entry.timeout);
94
+ entry.cleanup?.();
95
+ entry.reject(error);
96
+ }
97
+
98
+ return entries.length;
99
+ }
100
+
101
+ /** Generates a unique correlation ID for a new request. */
102
+ static generateCorrelationId(): string {
103
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
104
+ }
105
+ }