@laot/nuix 1.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/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/index.cjs +168 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +248 -0
- package/dist/index.d.ts +248 -0
- package/dist/index.js +137 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LAOT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# NUIX
|
|
2
|
+
|
|
3
|
+
> Modular, type-safe TypeScript library for FiveM NUI projects. Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @laot/nuix
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### 1. Define Your Event Map
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import type { NuiEventMap } from "@laot/nuix";
|
|
17
|
+
|
|
18
|
+
interface MyEvents extends NuiEventMap {
|
|
19
|
+
getPlayer: { request: { id: number }; response: { name: string; level: number } };
|
|
20
|
+
sendNotify: { request: { message: string }; response: void };
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 2. Typed FetchNui
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createFetchNui } from "@laot/nuix";
|
|
28
|
+
|
|
29
|
+
const fetchNui = createFetchNui<MyEvents>();
|
|
30
|
+
|
|
31
|
+
// Fully typed — request and response inferred from event map
|
|
32
|
+
const player = await fetchNui("getPlayer", { id: 1 });
|
|
33
|
+
console.log(player.name); // ✅ typed as string
|
|
34
|
+
|
|
35
|
+
await fetchNui("sendNotify", { message: "Hello!" });
|
|
36
|
+
|
|
37
|
+
// With timeout (throws descriptive error on timeout)
|
|
38
|
+
const data = await fetchNui("getPlayer", { id: 2 }, { timeout: 5000 });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 3. NUI Message Listener
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { onNuiMessage } from "@laot/nuix";
|
|
45
|
+
|
|
46
|
+
interface MyMessages extends NuiEventMap {
|
|
47
|
+
showMenu: { request: { items: string[] }; response: void };
|
|
48
|
+
hideMenu: { request: void; response: void };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const unsub = onNuiMessage<MyMessages, "showMenu">("showMenu", (data) => {
|
|
52
|
+
console.log(data.items); // ✅ typed as string[]
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Clean up
|
|
56
|
+
unsub();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 4. Lua-Style Formatter
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { luaFormat } from "@laot/nuix";
|
|
63
|
+
|
|
64
|
+
luaFormat("Hello %s, you are level %d", "Laot", 42);
|
|
65
|
+
// → "Hello Laot, you are level 42"
|
|
66
|
+
|
|
67
|
+
luaFormat("Accuracy: %f%%", 99.5);
|
|
68
|
+
// → "Accuracy: 99.5%"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 5. Translator
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { createTranslator, mergeLocales } from "@laot/nuix";
|
|
75
|
+
|
|
76
|
+
const _U = createTranslator({
|
|
77
|
+
locales: {
|
|
78
|
+
client: {
|
|
79
|
+
greeting: "Hello %s!",
|
|
80
|
+
level: "Level %d",
|
|
81
|
+
},
|
|
82
|
+
server: {
|
|
83
|
+
error: "Error: %s",
|
|
84
|
+
},
|
|
85
|
+
flat_key: "Plain message: %s",
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
_U("client.greeting", "MISSING", "Laot"); // → "Hello Laot!"
|
|
90
|
+
_U("client.level", "MISSING", 42); // → "Level 42"
|
|
91
|
+
_U("flat_key", "MISSING", "test"); // → "Plain message: test"
|
|
92
|
+
_U("no.key", "Not found"); // → "Not found"
|
|
93
|
+
|
|
94
|
+
// Deep-merge multiple locale files
|
|
95
|
+
const base = { client: { greeting: "Hello %s!" } };
|
|
96
|
+
const overrides = { client: { greeting: "Hey %s, welcome back!" } };
|
|
97
|
+
const merged = mergeLocales(base, overrides);
|
|
98
|
+
const _T = createTranslator({ locales: merged });
|
|
99
|
+
|
|
100
|
+
_T("client.greeting", "MISSING", "Laot"); // → "Hey Laot, welcome back!"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 6. Debug Mode
|
|
104
|
+
|
|
105
|
+
Enable console logging for every `fetchNui` call — useful during local development:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const fetchNui = createFetchNui<MyEvents>({ debug: true });
|
|
109
|
+
|
|
110
|
+
await fetchNui("getPlayer", { id: 1 });
|
|
111
|
+
// Console:
|
|
112
|
+
// [NUIX] → getPlayer { id: 1 }
|
|
113
|
+
// [NUIX] ← getPlayer { name: "Laot", level: 42 }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 7. Mock Data (Local Development)
|
|
117
|
+
|
|
118
|
+
When developing outside FiveM, `fetchNui` can return pre-defined mock responses instead of making real HTTP calls:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const fetchNui = createFetchNui<MyEvents>({
|
|
122
|
+
debug: true,
|
|
123
|
+
mockData: {
|
|
124
|
+
// Static response
|
|
125
|
+
getPlayer: { name: "DevPlayer", level: 99 },
|
|
126
|
+
|
|
127
|
+
// Dynamic response based on request
|
|
128
|
+
sendNotify: (req) => {
|
|
129
|
+
console.log("Mock notification:", req.message);
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const player = await fetchNui("getPlayer", { id: 1 });
|
|
135
|
+
// Console: [NUIX] → getPlayer { id: 1 }
|
|
136
|
+
// Console: [NUIX] ← getPlayer (mock) { name: "DevPlayer", level: 99 }
|
|
137
|
+
// player = { name: "DevPlayer", level: 99 }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Lua Callback Example
|
|
141
|
+
|
|
142
|
+
```lua
|
|
143
|
+
-- client side
|
|
144
|
+
RegisterNUICallback("getPlayer", function(data, cb)
|
|
145
|
+
local player = GetPlayerData(data.id)
|
|
146
|
+
cb({ name = player.name, level = player.level })
|
|
147
|
+
end)
|
|
148
|
+
|
|
149
|
+
-- sending messages to NUI
|
|
150
|
+
SendNUIMessage({ action = "showMenu", data = { items = {"Pistol", "Rifle"} } })
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## API Reference
|
|
154
|
+
|
|
155
|
+
| Export | Type | Description |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `createFetchNui<TMap>(options?)` | Factory | Returns a typed `fetchNui` function (supports debug & mock) |
|
|
158
|
+
| `onNuiMessage<TMap, K>(action, handler)` | Function | Listens for NUI messages by action |
|
|
159
|
+
| `luaFormat(template, ...args)` | Function | Lua-style `%s`/`%d`/`%f` formatter |
|
|
160
|
+
| `createTranslator(options)` | Factory | Returns a `_U` translator function |
|
|
161
|
+
| `mergeLocales(...records)` | Function | Deep-merges locale records |
|
|
162
|
+
|
|
163
|
+
## Build
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm run build # ESM + CJS + .d.ts
|
|
167
|
+
npm run typecheck # tsc --noEmit
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createFetchNui: () => createFetchNui,
|
|
24
|
+
createTranslator: () => createTranslator,
|
|
25
|
+
luaFormat: () => luaFormat,
|
|
26
|
+
mergeLocales: () => mergeLocales,
|
|
27
|
+
onNuiMessage: () => onNuiMessage
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/client.ts
|
|
32
|
+
function getResourceName() {
|
|
33
|
+
if (typeof window !== "undefined" && window.GetParentResourceName) {
|
|
34
|
+
return window.GetParentResourceName();
|
|
35
|
+
}
|
|
36
|
+
return "nui-frame-app";
|
|
37
|
+
}
|
|
38
|
+
function createFetchNui(factoryOptions) {
|
|
39
|
+
const debug = factoryOptions?.debug ?? false;
|
|
40
|
+
const mockData = factoryOptions?.mockData;
|
|
41
|
+
return async function fetchNui(event, data, options) {
|
|
42
|
+
if (debug) {
|
|
43
|
+
console.log(`[NUIX] \u2192 ${event}`, data ?? {});
|
|
44
|
+
}
|
|
45
|
+
if (mockData && event in mockData) {
|
|
46
|
+
const mock = mockData[event];
|
|
47
|
+
if (mock === void 0) {
|
|
48
|
+
throw new Error(`[NUIX] Mock data for "${event}" is undefined`);
|
|
49
|
+
}
|
|
50
|
+
const result = typeof mock === "function" ? mock(data) : mock;
|
|
51
|
+
if (debug) {
|
|
52
|
+
console.log(`[NUIX] \u2190 ${event} (mock)`, result);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
const url = `https://${getResourceName()}/${event}`;
|
|
57
|
+
const controller = options?.timeout ? new AbortController() : void 0;
|
|
58
|
+
let timeoutId;
|
|
59
|
+
if (controller && options?.timeout) {
|
|
60
|
+
timeoutId = setTimeout(() => controller.abort(), options.timeout);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(url, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json; charset=UTF-8" },
|
|
66
|
+
body: JSON.stringify(data ?? {}),
|
|
67
|
+
signal: controller?.signal
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`[NUIX] fetchNui("${event}") failed with HTTP ${response.status}`);
|
|
71
|
+
}
|
|
72
|
+
const result = await response.json();
|
|
73
|
+
if (debug) {
|
|
74
|
+
console.log(`[NUIX] \u2190 ${event}`, result);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
79
|
+
throw new Error(`[NUIX] fetchNui("${event}") timed out after ${options?.timeout}ms`);
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
} finally {
|
|
83
|
+
if (timeoutId !== void 0) clearTimeout(timeoutId);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/listener.ts
|
|
89
|
+
function onNuiMessage(action, handler) {
|
|
90
|
+
const listener = (event) => {
|
|
91
|
+
const payload = event.data;
|
|
92
|
+
if (!payload || typeof payload !== "object") return;
|
|
93
|
+
if (payload.action !== action) return;
|
|
94
|
+
handler(payload.data);
|
|
95
|
+
};
|
|
96
|
+
window.addEventListener("message", listener);
|
|
97
|
+
return () => {
|
|
98
|
+
window.removeEventListener("message", listener);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/utils.ts
|
|
103
|
+
function luaFormat(template, ...args) {
|
|
104
|
+
let argIndex = 0;
|
|
105
|
+
return template.replace(/%([sdfi%])/g, (match, specifier) => {
|
|
106
|
+
if (specifier === "%") return "%";
|
|
107
|
+
const raw = args[argIndex++];
|
|
108
|
+
switch (specifier) {
|
|
109
|
+
case "s":
|
|
110
|
+
return String(raw ?? "");
|
|
111
|
+
case "d":
|
|
112
|
+
case "i": {
|
|
113
|
+
const num = Number(raw);
|
|
114
|
+
return String(Number.isNaN(num) ? 0 : Math.floor(num));
|
|
115
|
+
}
|
|
116
|
+
case "f": {
|
|
117
|
+
const num = Number(raw);
|
|
118
|
+
return String(Number.isNaN(num) ? 0 : num);
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
return match;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function resolveKey(locales, key) {
|
|
126
|
+
const segments = key.split(".");
|
|
127
|
+
let current = locales;
|
|
128
|
+
for (const segment of segments) {
|
|
129
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
130
|
+
const next = current[segment];
|
|
131
|
+
if (next === void 0) return void 0;
|
|
132
|
+
current = next;
|
|
133
|
+
}
|
|
134
|
+
return typeof current === "string" ? current : void 0;
|
|
135
|
+
}
|
|
136
|
+
function createTranslator(options) {
|
|
137
|
+
const { locales } = options;
|
|
138
|
+
return (key, fallback, ...args) => {
|
|
139
|
+
const template = resolveKey(locales, key);
|
|
140
|
+
if (template === void 0) {
|
|
141
|
+
return fallback;
|
|
142
|
+
}
|
|
143
|
+
return args.length > 0 ? luaFormat(template, ...args) : template;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function mergeLocales(...records) {
|
|
147
|
+
const result = {};
|
|
148
|
+
for (const record of records) {
|
|
149
|
+
for (const [key, value] of Object.entries(record)) {
|
|
150
|
+
const existing = result[key];
|
|
151
|
+
if (typeof value === "object" && typeof existing === "object" && existing !== void 0) {
|
|
152
|
+
result[key] = mergeLocales(existing, value);
|
|
153
|
+
} else {
|
|
154
|
+
result[key] = value;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
161
|
+
0 && (module.exports = {
|
|
162
|
+
createFetchNui,
|
|
163
|
+
createTranslator,
|
|
164
|
+
luaFormat,
|
|
165
|
+
mergeLocales,
|
|
166
|
+
onNuiMessage
|
|
167
|
+
});
|
|
168
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/client.ts","../src/listener.ts","../src/utils.ts"],"sourcesContent":["// ─── Types ───\r\nexport type {\r\n\tNuiEventMap,\r\n\tNuiMessagePayload,\r\n\tFetchNuiOptions,\r\n\tFetchNuiFactoryOptions,\r\n\tLocaleRecord,\r\n\tTranslatorOptions,\r\n\tTranslatorFn,\r\n\tFormatArg,\r\n\tUnsubscribeFn,\r\n\tNuiMessageHandler,\r\n} from \"./types\";\r\n\r\n// ─── Client ───\r\nexport { createFetchNui } from \"./client\";\r\n\r\n// ─── Listener ───\r\nexport { onNuiMessage } from \"./listener\";\r\n\r\n// ─── Utils ───\r\nexport { luaFormat, createTranslator, mergeLocales } from \"./utils\";\r\n","import type { NuiEventMap, FetchNuiOptions, FetchNuiFactoryOptions } from \"./types\";\r\n\r\n// ─── Resource Name ───\r\n\r\n/**\r\n * Grabs the resource name from FiveM's injected global.\r\n * Falls back to `\"nui-frame-app\"` when running outside the game (local dev, tests).\r\n */\r\nfunction getResourceName(): string {\r\n\tif (typeof window !== \"undefined\" && window.GetParentResourceName) {\r\n\t\treturn window.GetParentResourceName();\r\n\t}\r\n\treturn \"nui-frame-app\";\r\n}\r\n\r\n// ─── FetchNui Factory ───\r\n\r\n/**\r\n * Creates a fully typed `fetchNui` function tied to your event map.\r\n *\r\n * The returned function POSTs JSON to `https://<resourceName>/<event>`,\r\n * which maps 1:1 to `RegisterNUICallback` on the Lua side.\r\n *\r\n * Supports optional debug logging, mock data for local dev, and per-call timeout.\r\n *\r\n * @example\r\n * ```ts\r\n * interface MyEvents extends NuiEventMap {\r\n * getPlayer: { request: { id: number }; response: { name: string } };\r\n * notify: { request: { msg: string }; response: void };\r\n * }\r\n *\r\n * // Production usage\r\n * const fetchNui = createFetchNui<MyEvents>();\r\n * const player = await fetchNui(\"getPlayer\", { id: 1 });\r\n * // player.name is typed as string\r\n *\r\n * // Local dev with mocks + debug\r\n * const fetchNui = createFetchNui<MyEvents>({\r\n * debug: true,\r\n * mockData: {\r\n * getPlayer: { name: \"DevPlayer\" },\r\n * notify: (req) => { console.log(\"Mock:\", req.msg); },\r\n * },\r\n * });\r\n * ```\r\n *\r\n * Lua side:\r\n * ```lua\r\n * RegisterNUICallback(\"getPlayer\", function(data, cb)\r\n * local player = GetPlayerData(data.id)\r\n * cb({ name = player.name })\r\n * end)\r\n * ```\r\n */\r\nexport function createFetchNui<TMap extends NuiEventMap>(factoryOptions?: FetchNuiFactoryOptions<TMap>) {\r\n\tconst debug = factoryOptions?.debug ?? false;\r\n\tconst mockData = factoryOptions?.mockData;\r\n\r\n\treturn async function fetchNui<K extends keyof TMap & string>(\r\n\t\tevent: K,\r\n\t\tdata?: TMap[K][\"request\"],\r\n\t\toptions?: FetchNuiOptions,\r\n\t): Promise<TMap[K][\"response\"]> {\r\n\t\tif (debug) {\r\n\t\t\tconsole.log(`[NUIX] → ${event}`, data ?? {});\r\n\t\t}\r\n\r\n\t\t// ─── Mock Mode ───\r\n\r\n\t\tif (mockData && event in mockData) {\r\n\t\t\tconst mock = mockData[event];\r\n\r\n\t\t\tif (mock === undefined) {\r\n\t\t\t\tthrow new Error(`[NUIX] Mock data for \"${event}\" is undefined`);\r\n\t\t\t}\r\n\r\n\t\t\tconst result =\r\n\t\t\t\ttypeof mock === \"function\"\r\n\t\t\t\t\t? (mock as (req: TMap[K][\"request\"]) => TMap[K][\"response\"])(data as TMap[K][\"request\"])\r\n\t\t\t\t\t: mock;\r\n\r\n\t\t\tif (debug) {\r\n\t\t\t\tconsole.log(`[NUIX] ← ${event} (mock)`, result);\r\n\t\t\t}\r\n\r\n\t\t\treturn result as TMap[K][\"response\"];\r\n\t\t}\r\n\r\n\t\t// ─── Real Fetch ───\r\n\r\n\t\tconst url = `https://${getResourceName()}/${event}`;\r\n\r\n\t\tconst controller = options?.timeout ? new AbortController() : undefined;\r\n\t\tlet timeoutId: ReturnType<typeof setTimeout> | undefined;\r\n\r\n\t\tif (controller && options?.timeout) {\r\n\t\t\ttimeoutId = setTimeout(() => controller.abort(), options.timeout);\r\n\t\t}\r\n\r\n\t\ttry {\r\n\t\t\tconst response = await fetch(url, {\r\n\t\t\t\tmethod: \"POST\",\r\n\t\t\t\theaders: { \"Content-Type\": \"application/json; charset=UTF-8\" },\r\n\t\t\t\tbody: JSON.stringify(data ?? {}),\r\n\t\t\t\tsignal: controller?.signal,\r\n\t\t\t});\r\n\r\n\t\t\tif (!response.ok) {\r\n\t\t\t\tthrow new Error(`[NUIX] fetchNui(\"${event}\") failed with HTTP ${response.status}`);\r\n\t\t\t}\r\n\r\n\t\t\tconst result = (await response.json()) as TMap[K][\"response\"];\r\n\r\n\t\t\tif (debug) {\r\n\t\t\t\tconsole.log(`[NUIX] ← ${event}`, result);\r\n\t\t\t}\r\n\r\n\t\t\treturn result;\r\n\t\t} catch (error) {\r\n\t\t\tif (error instanceof DOMException && error.name === \"AbortError\") {\r\n\t\t\t\tthrow new Error(`[NUIX] fetchNui(\"${event}\") timed out after ${options?.timeout}ms`);\r\n\t\t\t}\r\n\t\t\tthrow error;\r\n\t\t} finally {\r\n\t\t\tif (timeoutId !== undefined) clearTimeout(timeoutId);\r\n\t\t}\r\n\t};\r\n}\r\n","import type { NuiEventMap, NuiMessagePayload, NuiMessageHandler, UnsubscribeFn } from \"./types\";\r\n\r\n// ─── NUI Message Listener ───\r\n\r\n/**\r\n * Listens for NUI messages from Lua (`SendNUIMessage`), filtered by action name.\r\n * Only messages matching the given `action` trigger the handler.\r\n * Returns a cleanup function to stop listening.\r\n *\r\n * @example\r\n * ```ts\r\n * interface MyMessages extends NuiEventMap {\r\n * showMenu: { request: { items: string[] }; response: void };\r\n * hideMenu: { request: void; response: void };\r\n * }\r\n *\r\n * const unsub = onNuiMessage<MyMessages, \"showMenu\">(\"showMenu\", (data) => {\r\n * console.log(data.items); // typed as string[]\r\n * });\r\n *\r\n * // Stop listening when done\r\n * unsub();\r\n * ```\r\n *\r\n * Lua side:\r\n * ```lua\r\n * SendNUIMessage({ action = \"showMenu\", data = { items = {\"Pistol\", \"Rifle\"} } })\r\n * ```\r\n */\r\nexport function onNuiMessage<TMap extends NuiEventMap, K extends keyof TMap & string>(\r\n\taction: K,\r\n\thandler: NuiMessageHandler<TMap[K][\"request\"]>,\r\n): UnsubscribeFn {\r\n\tconst listener = (event: MessageEvent<NuiMessagePayload<TMap[K][\"request\"]>>) => {\r\n\t\tconst payload = event.data;\r\n\r\n\t\tif (!payload || typeof payload !== \"object\") return;\r\n\t\tif (payload.action !== action) return;\r\n\r\n\t\thandler(payload.data);\r\n\t};\r\n\r\n\twindow.addEventListener(\"message\", listener);\r\n\r\n\treturn () => {\r\n\t\twindow.removeEventListener(\"message\", listener);\r\n\t};\r\n}\r\n","import type { FormatArg, LocaleRecord, TranslatorFn, TranslatorOptions } from \"./types\";\r\n\r\n// ─── Lua-Style Formatter ───\r\n\r\n/**\r\n * Formats a string using Lua-style placeholders.\r\n *\r\n * Specifiers:\r\n * - `%s` — string (null/undefined becomes empty string)\r\n * - `%d` — integer (floors the value, NaN becomes 0)\r\n * - `%f` — float (NaN becomes 0)\r\n * - `%%` — literal percent sign\r\n *\r\n * @example\r\n * ```ts\r\n * luaFormat(\"Hello %s, you are level %d\", \"Laot\", 42);\r\n * // → \"Hello Laot, you are level 42\"\r\n *\r\n * luaFormat(\"Accuracy: %f%%\", 99.5);\r\n * // → \"Accuracy: 99.5%\"\r\n *\r\n * luaFormat(\"Safe: %s %d\", undefined, NaN);\r\n * // → \"Safe: 0\"\r\n * ```\r\n */\r\nexport function luaFormat(template: string, ...args: FormatArg[]): string {\r\n\tlet argIndex = 0;\r\n\r\n\treturn template.replace(/%([sdfi%])/g, (match, specifier: string) => {\r\n\t\tif (specifier === \"%\") return \"%\";\r\n\r\n\t\tconst raw = args[argIndex++];\r\n\r\n\t\tswitch (specifier) {\r\n\t\t\tcase \"s\":\r\n\t\t\t\treturn String(raw ?? \"\");\r\n\r\n\t\t\tcase \"d\":\r\n\t\t\tcase \"i\": {\r\n\t\t\t\tconst num = Number(raw);\r\n\t\t\t\treturn String(Number.isNaN(num) ? 0 : Math.floor(num));\r\n\t\t\t}\r\n\r\n\t\t\tcase \"f\": {\r\n\t\t\t\tconst num = Number(raw);\r\n\t\t\t\treturn String(Number.isNaN(num) ? 0 : num);\r\n\t\t\t}\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn match;\r\n\t\t}\r\n\t});\r\n}\r\n\r\n// ─── Dot-Notation Resolver ───\r\n\r\n/**\r\n * Walks a nested locale object by splitting the key on `.`\r\n * Returns the string value at the end of the path, or `undefined` if any segment is missing.\r\n *\r\n * `\"client.greeting\"` → looks up `locales.client.greeting`\r\n * `\"flat_key\"` → looks up `locales.flat_key` directly\r\n */\r\nfunction resolveKey(locales: LocaleRecord, key: string): string | undefined {\r\n\tconst segments = key.split(\".\");\r\n\tlet current: LocaleRecord | string = locales;\r\n\r\n\tfor (const segment of segments) {\r\n\t\tif (typeof current !== \"object\" || current === null) return undefined;\r\n\t\tconst next: string | LocaleRecord | undefined = current[segment];\r\n\t\tif (next === undefined) return undefined;\r\n\t\tcurrent = next;\r\n\t}\r\n\r\n\treturn typeof current === \"string\" ? current : undefined;\r\n}\r\n\r\n// ─── Translator Factory ───\r\n\r\n/**\r\n * Creates a `_U(key, fallback, ...args)` translator bound to a locale record.\r\n *\r\n * The key supports dot-notation to traverse nested locale objects.\r\n * If the key isn't found, the fallback string is returned as-is.\r\n * Any extra args are passed through `luaFormat` for placeholder substitution.\r\n *\r\n * @example\r\n * ```ts\r\n * const _U = createTranslator({\r\n * locales: {\r\n * client: {\r\n * greeting: \"Hello %s!\",\r\n * level: \"Level %d\",\r\n * },\r\n * server: {\r\n * error: \"Error: %s\",\r\n * },\r\n * flat_key: \"Plain message\",\r\n * },\r\n * });\r\n *\r\n * _U(\"client.greeting\", \"MISSING\", \"Laot\"); // → \"Hello Laot!\"\r\n * _U(\"client.level\", \"MISSING\", 42); // → \"Level 42\"\r\n * _U(\"flat_key\", \"MISSING\"); // → \"Plain message\"\r\n * _U(\"no.key\", \"Not found\"); // → \"Not found\"\r\n * ```\r\n */\r\nexport function createTranslator(options: TranslatorOptions): TranslatorFn {\r\n\tconst { locales } = options;\r\n\r\n\treturn (key: string, fallback: string, ...args: FormatArg[]): string => {\r\n\t\tconst template = resolveKey(locales, key);\r\n\r\n\t\tif (template === undefined) {\r\n\t\t\treturn fallback;\r\n\t\t}\r\n\r\n\t\treturn args.length > 0 ? luaFormat(template, ...args) : template;\r\n\t};\r\n}\r\n\r\n// ─── Deep Merge ───\r\n\r\n/**\r\n * Deep-merges multiple locale records into one.\r\n * Later records override earlier ones on key conflicts.\r\n * Nested objects are merged recursively, not replaced entirely.\r\n *\r\n * @example\r\n * ```ts\r\n * const base = { client: { greeting: \"Hello %s!\", level: \"Level %d\" } };\r\n * const overrides = { client: { greeting: \"Hey %s!\" } };\r\n *\r\n * const merged = mergeLocales(base, overrides);\r\n * // merged.client.greeting → \"Hey %s!\"\r\n * // merged.client.level → \"Level %d\" (preserved from base)\r\n * ```\r\n */\r\nexport function mergeLocales(...records: LocaleRecord[]): LocaleRecord {\r\n\tconst result: LocaleRecord = {};\r\n\r\n\tfor (const record of records) {\r\n\t\tfor (const [key, value] of Object.entries(record)) {\r\n\t\t\tconst existing = result[key];\r\n\r\n\t\t\tif (typeof value === \"object\" && typeof existing === \"object\" && existing !== undefined) {\r\n\t\t\t\tresult[key] = mergeLocales(existing, value);\r\n\t\t\t} else {\r\n\t\t\t\tresult[key] = value;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\treturn result;\r\n}\r\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extend this to map your NUI callback names to their request/response shapes.
|
|
3
|
+
* Both `fetchNui` and `onNuiMessage` use this map for full type inference.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* interface MyEvents extends NuiEventMap {
|
|
8
|
+
* getPlayer: { request: { id: number }; response: { name: string; level: number } };
|
|
9
|
+
* sendNotify: { request: { message: string }; response: void };
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* // Now fetchNui("getPlayer", { id: 1 }) returns Promise<{ name: string; level: number }>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
interface NuiEventMap {
|
|
16
|
+
[event: string]: {
|
|
17
|
+
request: unknown;
|
|
18
|
+
response: unknown;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The shape Lua sends via `SendNUIMessage`.
|
|
23
|
+
* Every message has an `action` string and a `data` payload.
|
|
24
|
+
*
|
|
25
|
+
* Lua side:
|
|
26
|
+
* ```lua
|
|
27
|
+
* SendNUIMessage({ action = "showMenu", data = { items = {"Pistol", "Rifle"} } })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
interface NuiMessagePayload<TData = unknown> {
|
|
31
|
+
action: string;
|
|
32
|
+
data: TData;
|
|
33
|
+
}
|
|
34
|
+
interface FetchNuiOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Timeout in ms. If the Lua callback doesn't respond within this window,
|
|
37
|
+
* the promise rejects with a descriptive timeout error.
|
|
38
|
+
*/
|
|
39
|
+
timeout?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Options passed to `createFetchNui()` at factory level.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const fetchNui = createFetchNui<MyEvents>({
|
|
47
|
+
* debug: true,
|
|
48
|
+
* mockData: {
|
|
49
|
+
* getPlayer: { name: "Dev", level: 99 },
|
|
50
|
+
* sendNotify: (req) => { console.log(req.message); },
|
|
51
|
+
* },
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
interface FetchNuiFactoryOptions<TMap extends NuiEventMap> {
|
|
56
|
+
/** Logs every `fetchNui` call and response to the console with `[NUIX]` prefix. */
|
|
57
|
+
debug?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Mock responses for local development outside FiveM.
|
|
60
|
+
* Can be a static value or a function that receives the request and returns the response.
|
|
61
|
+
* When a mock exists for an event, no HTTP call is made.
|
|
62
|
+
*/
|
|
63
|
+
mockData?: {
|
|
64
|
+
[K in keyof TMap]?: TMap[K]["response"] | ((request: TMap[K]["request"]) => TMap[K]["response"]);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Locale record — can be flat strings or nested objects.
|
|
69
|
+
* Nested keys are accessed via dot-notation in the translator.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* const locales: LocaleRecord = {
|
|
74
|
+
* client: {
|
|
75
|
+
* greeting: "Hello %s!",
|
|
76
|
+
* level: "Level %d",
|
|
77
|
+
* },
|
|
78
|
+
* simple_key: "No nesting here",
|
|
79
|
+
* };
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
type LocaleRecord = {
|
|
83
|
+
[key: string]: string | LocaleRecord;
|
|
84
|
+
};
|
|
85
|
+
interface TranslatorOptions {
|
|
86
|
+
/** The locale map. Same structure as `LocaleRecord` — flat or nested. */
|
|
87
|
+
locales: LocaleRecord;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Translator function returned by `createTranslator`.
|
|
91
|
+
*
|
|
92
|
+
* @param key - Dot-notated key like `'client.greeting'` or flat like `'title'`
|
|
93
|
+
* @param fallback - Returned when the key doesn't exist in the locale map
|
|
94
|
+
* @param args - Format arguments for `%s`, `%d`, `%f` placeholders
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* _U("client.greeting", "MISSING", "Laot"); // → "Hello Laot!"
|
|
99
|
+
* _U("no.key", "Not found"); // → "Not found"
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
type TranslatorFn = (key: string, fallback: string, ...args: FormatArg[]) => string;
|
|
103
|
+
/**
|
|
104
|
+
* Types that `luaFormat` accepts as arguments.
|
|
105
|
+
* `null` and `undefined` are handled safely — they won't crash the formatter.
|
|
106
|
+
*/
|
|
107
|
+
type FormatArg = string | number | boolean | null | undefined;
|
|
108
|
+
/** Cleanup function — call it to remove the listener. */
|
|
109
|
+
type UnsubscribeFn = () => void;
|
|
110
|
+
/** Callback invoked when a matching NUI message arrives. */
|
|
111
|
+
type NuiMessageHandler<TData = unknown> = (data: TData) => void;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates a fully typed `fetchNui` function tied to your event map.
|
|
115
|
+
*
|
|
116
|
+
* The returned function POSTs JSON to `https://<resourceName>/<event>`,
|
|
117
|
+
* which maps 1:1 to `RegisterNUICallback` on the Lua side.
|
|
118
|
+
*
|
|
119
|
+
* Supports optional debug logging, mock data for local dev, and per-call timeout.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* interface MyEvents extends NuiEventMap {
|
|
124
|
+
* getPlayer: { request: { id: number }; response: { name: string } };
|
|
125
|
+
* notify: { request: { msg: string }; response: void };
|
|
126
|
+
* }
|
|
127
|
+
*
|
|
128
|
+
* // Production usage
|
|
129
|
+
* const fetchNui = createFetchNui<MyEvents>();
|
|
130
|
+
* const player = await fetchNui("getPlayer", { id: 1 });
|
|
131
|
+
* // player.name is typed as string
|
|
132
|
+
*
|
|
133
|
+
* // Local dev with mocks + debug
|
|
134
|
+
* const fetchNui = createFetchNui<MyEvents>({
|
|
135
|
+
* debug: true,
|
|
136
|
+
* mockData: {
|
|
137
|
+
* getPlayer: { name: "DevPlayer" },
|
|
138
|
+
* notify: (req) => { console.log("Mock:", req.msg); },
|
|
139
|
+
* },
|
|
140
|
+
* });
|
|
141
|
+
* ```
|
|
142
|
+
*
|
|
143
|
+
* Lua side:
|
|
144
|
+
* ```lua
|
|
145
|
+
* RegisterNUICallback("getPlayer", function(data, cb)
|
|
146
|
+
* local player = GetPlayerData(data.id)
|
|
147
|
+
* cb({ name = player.name })
|
|
148
|
+
* end)
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
declare function createFetchNui<TMap extends NuiEventMap>(factoryOptions?: FetchNuiFactoryOptions<TMap>): <K extends keyof TMap & string>(event: K, data?: TMap[K]["request"], options?: FetchNuiOptions) => Promise<TMap[K]["response"]>;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Listens for NUI messages from Lua (`SendNUIMessage`), filtered by action name.
|
|
155
|
+
* Only messages matching the given `action` trigger the handler.
|
|
156
|
+
* Returns a cleanup function to stop listening.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* interface MyMessages extends NuiEventMap {
|
|
161
|
+
* showMenu: { request: { items: string[] }; response: void };
|
|
162
|
+
* hideMenu: { request: void; response: void };
|
|
163
|
+
* }
|
|
164
|
+
*
|
|
165
|
+
* const unsub = onNuiMessage<MyMessages, "showMenu">("showMenu", (data) => {
|
|
166
|
+
* console.log(data.items); // typed as string[]
|
|
167
|
+
* });
|
|
168
|
+
*
|
|
169
|
+
* // Stop listening when done
|
|
170
|
+
* unsub();
|
|
171
|
+
* ```
|
|
172
|
+
*
|
|
173
|
+
* Lua side:
|
|
174
|
+
* ```lua
|
|
175
|
+
* SendNUIMessage({ action = "showMenu", data = { items = {"Pistol", "Rifle"} } })
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
declare function onNuiMessage<TMap extends NuiEventMap, K extends keyof TMap & string>(action: K, handler: NuiMessageHandler<TMap[K]["request"]>): UnsubscribeFn;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Formats a string using Lua-style placeholders.
|
|
182
|
+
*
|
|
183
|
+
* Specifiers:
|
|
184
|
+
* - `%s` — string (null/undefined becomes empty string)
|
|
185
|
+
* - `%d` — integer (floors the value, NaN becomes 0)
|
|
186
|
+
* - `%f` — float (NaN becomes 0)
|
|
187
|
+
* - `%%` — literal percent sign
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* luaFormat("Hello %s, you are level %d", "Laot", 42);
|
|
192
|
+
* // → "Hello Laot, you are level 42"
|
|
193
|
+
*
|
|
194
|
+
* luaFormat("Accuracy: %f%%", 99.5);
|
|
195
|
+
* // → "Accuracy: 99.5%"
|
|
196
|
+
*
|
|
197
|
+
* luaFormat("Safe: %s %d", undefined, NaN);
|
|
198
|
+
* // → "Safe: 0"
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
declare function luaFormat(template: string, ...args: FormatArg[]): string;
|
|
202
|
+
/**
|
|
203
|
+
* Creates a `_U(key, fallback, ...args)` translator bound to a locale record.
|
|
204
|
+
*
|
|
205
|
+
* The key supports dot-notation to traverse nested locale objects.
|
|
206
|
+
* If the key isn't found, the fallback string is returned as-is.
|
|
207
|
+
* Any extra args are passed through `luaFormat` for placeholder substitution.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* const _U = createTranslator({
|
|
212
|
+
* locales: {
|
|
213
|
+
* client: {
|
|
214
|
+
* greeting: "Hello %s!",
|
|
215
|
+
* level: "Level %d",
|
|
216
|
+
* },
|
|
217
|
+
* server: {
|
|
218
|
+
* error: "Error: %s",
|
|
219
|
+
* },
|
|
220
|
+
* flat_key: "Plain message",
|
|
221
|
+
* },
|
|
222
|
+
* });
|
|
223
|
+
*
|
|
224
|
+
* _U("client.greeting", "MISSING", "Laot"); // → "Hello Laot!"
|
|
225
|
+
* _U("client.level", "MISSING", 42); // → "Level 42"
|
|
226
|
+
* _U("flat_key", "MISSING"); // → "Plain message"
|
|
227
|
+
* _U("no.key", "Not found"); // → "Not found"
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
declare function createTranslator(options: TranslatorOptions): TranslatorFn;
|
|
231
|
+
/**
|
|
232
|
+
* Deep-merges multiple locale records into one.
|
|
233
|
+
* Later records override earlier ones on key conflicts.
|
|
234
|
+
* Nested objects are merged recursively, not replaced entirely.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```ts
|
|
238
|
+
* const base = { client: { greeting: "Hello %s!", level: "Level %d" } };
|
|
239
|
+
* const overrides = { client: { greeting: "Hey %s!" } };
|
|
240
|
+
*
|
|
241
|
+
* const merged = mergeLocales(base, overrides);
|
|
242
|
+
* // merged.client.greeting → "Hey %s!"
|
|
243
|
+
* // merged.client.level → "Level %d" (preserved from base)
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
declare function mergeLocales(...records: LocaleRecord[]): LocaleRecord;
|
|
247
|
+
|
|
248
|
+
export { type FetchNuiFactoryOptions, type FetchNuiOptions, type FormatArg, type LocaleRecord, type NuiEventMap, type NuiMessageHandler, type NuiMessagePayload, type TranslatorFn, type TranslatorOptions, type UnsubscribeFn, createFetchNui, createTranslator, luaFormat, mergeLocales, onNuiMessage };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extend this to map your NUI callback names to their request/response shapes.
|
|
3
|
+
* Both `fetchNui` and `onNuiMessage` use this map for full type inference.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* interface MyEvents extends NuiEventMap {
|
|
8
|
+
* getPlayer: { request: { id: number }; response: { name: string; level: number } };
|
|
9
|
+
* sendNotify: { request: { message: string }; response: void };
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* // Now fetchNui("getPlayer", { id: 1 }) returns Promise<{ name: string; level: number }>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
interface NuiEventMap {
|
|
16
|
+
[event: string]: {
|
|
17
|
+
request: unknown;
|
|
18
|
+
response: unknown;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The shape Lua sends via `SendNUIMessage`.
|
|
23
|
+
* Every message has an `action` string and a `data` payload.
|
|
24
|
+
*
|
|
25
|
+
* Lua side:
|
|
26
|
+
* ```lua
|
|
27
|
+
* SendNUIMessage({ action = "showMenu", data = { items = {"Pistol", "Rifle"} } })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
interface NuiMessagePayload<TData = unknown> {
|
|
31
|
+
action: string;
|
|
32
|
+
data: TData;
|
|
33
|
+
}
|
|
34
|
+
interface FetchNuiOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Timeout in ms. If the Lua callback doesn't respond within this window,
|
|
37
|
+
* the promise rejects with a descriptive timeout error.
|
|
38
|
+
*/
|
|
39
|
+
timeout?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Options passed to `createFetchNui()` at factory level.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const fetchNui = createFetchNui<MyEvents>({
|
|
47
|
+
* debug: true,
|
|
48
|
+
* mockData: {
|
|
49
|
+
* getPlayer: { name: "Dev", level: 99 },
|
|
50
|
+
* sendNotify: (req) => { console.log(req.message); },
|
|
51
|
+
* },
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
interface FetchNuiFactoryOptions<TMap extends NuiEventMap> {
|
|
56
|
+
/** Logs every `fetchNui` call and response to the console with `[NUIX]` prefix. */
|
|
57
|
+
debug?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Mock responses for local development outside FiveM.
|
|
60
|
+
* Can be a static value or a function that receives the request and returns the response.
|
|
61
|
+
* When a mock exists for an event, no HTTP call is made.
|
|
62
|
+
*/
|
|
63
|
+
mockData?: {
|
|
64
|
+
[K in keyof TMap]?: TMap[K]["response"] | ((request: TMap[K]["request"]) => TMap[K]["response"]);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Locale record — can be flat strings or nested objects.
|
|
69
|
+
* Nested keys are accessed via dot-notation in the translator.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* const locales: LocaleRecord = {
|
|
74
|
+
* client: {
|
|
75
|
+
* greeting: "Hello %s!",
|
|
76
|
+
* level: "Level %d",
|
|
77
|
+
* },
|
|
78
|
+
* simple_key: "No nesting here",
|
|
79
|
+
* };
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
type LocaleRecord = {
|
|
83
|
+
[key: string]: string | LocaleRecord;
|
|
84
|
+
};
|
|
85
|
+
interface TranslatorOptions {
|
|
86
|
+
/** The locale map. Same structure as `LocaleRecord` — flat or nested. */
|
|
87
|
+
locales: LocaleRecord;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Translator function returned by `createTranslator`.
|
|
91
|
+
*
|
|
92
|
+
* @param key - Dot-notated key like `'client.greeting'` or flat like `'title'`
|
|
93
|
+
* @param fallback - Returned when the key doesn't exist in the locale map
|
|
94
|
+
* @param args - Format arguments for `%s`, `%d`, `%f` placeholders
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* _U("client.greeting", "MISSING", "Laot"); // → "Hello Laot!"
|
|
99
|
+
* _U("no.key", "Not found"); // → "Not found"
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
type TranslatorFn = (key: string, fallback: string, ...args: FormatArg[]) => string;
|
|
103
|
+
/**
|
|
104
|
+
* Types that `luaFormat` accepts as arguments.
|
|
105
|
+
* `null` and `undefined` are handled safely — they won't crash the formatter.
|
|
106
|
+
*/
|
|
107
|
+
type FormatArg = string | number | boolean | null | undefined;
|
|
108
|
+
/** Cleanup function — call it to remove the listener. */
|
|
109
|
+
type UnsubscribeFn = () => void;
|
|
110
|
+
/** Callback invoked when a matching NUI message arrives. */
|
|
111
|
+
type NuiMessageHandler<TData = unknown> = (data: TData) => void;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates a fully typed `fetchNui` function tied to your event map.
|
|
115
|
+
*
|
|
116
|
+
* The returned function POSTs JSON to `https://<resourceName>/<event>`,
|
|
117
|
+
* which maps 1:1 to `RegisterNUICallback` on the Lua side.
|
|
118
|
+
*
|
|
119
|
+
* Supports optional debug logging, mock data for local dev, and per-call timeout.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* interface MyEvents extends NuiEventMap {
|
|
124
|
+
* getPlayer: { request: { id: number }; response: { name: string } };
|
|
125
|
+
* notify: { request: { msg: string }; response: void };
|
|
126
|
+
* }
|
|
127
|
+
*
|
|
128
|
+
* // Production usage
|
|
129
|
+
* const fetchNui = createFetchNui<MyEvents>();
|
|
130
|
+
* const player = await fetchNui("getPlayer", { id: 1 });
|
|
131
|
+
* // player.name is typed as string
|
|
132
|
+
*
|
|
133
|
+
* // Local dev with mocks + debug
|
|
134
|
+
* const fetchNui = createFetchNui<MyEvents>({
|
|
135
|
+
* debug: true,
|
|
136
|
+
* mockData: {
|
|
137
|
+
* getPlayer: { name: "DevPlayer" },
|
|
138
|
+
* notify: (req) => { console.log("Mock:", req.msg); },
|
|
139
|
+
* },
|
|
140
|
+
* });
|
|
141
|
+
* ```
|
|
142
|
+
*
|
|
143
|
+
* Lua side:
|
|
144
|
+
* ```lua
|
|
145
|
+
* RegisterNUICallback("getPlayer", function(data, cb)
|
|
146
|
+
* local player = GetPlayerData(data.id)
|
|
147
|
+
* cb({ name = player.name })
|
|
148
|
+
* end)
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
declare function createFetchNui<TMap extends NuiEventMap>(factoryOptions?: FetchNuiFactoryOptions<TMap>): <K extends keyof TMap & string>(event: K, data?: TMap[K]["request"], options?: FetchNuiOptions) => Promise<TMap[K]["response"]>;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Listens for NUI messages from Lua (`SendNUIMessage`), filtered by action name.
|
|
155
|
+
* Only messages matching the given `action` trigger the handler.
|
|
156
|
+
* Returns a cleanup function to stop listening.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* interface MyMessages extends NuiEventMap {
|
|
161
|
+
* showMenu: { request: { items: string[] }; response: void };
|
|
162
|
+
* hideMenu: { request: void; response: void };
|
|
163
|
+
* }
|
|
164
|
+
*
|
|
165
|
+
* const unsub = onNuiMessage<MyMessages, "showMenu">("showMenu", (data) => {
|
|
166
|
+
* console.log(data.items); // typed as string[]
|
|
167
|
+
* });
|
|
168
|
+
*
|
|
169
|
+
* // Stop listening when done
|
|
170
|
+
* unsub();
|
|
171
|
+
* ```
|
|
172
|
+
*
|
|
173
|
+
* Lua side:
|
|
174
|
+
* ```lua
|
|
175
|
+
* SendNUIMessage({ action = "showMenu", data = { items = {"Pistol", "Rifle"} } })
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
declare function onNuiMessage<TMap extends NuiEventMap, K extends keyof TMap & string>(action: K, handler: NuiMessageHandler<TMap[K]["request"]>): UnsubscribeFn;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Formats a string using Lua-style placeholders.
|
|
182
|
+
*
|
|
183
|
+
* Specifiers:
|
|
184
|
+
* - `%s` — string (null/undefined becomes empty string)
|
|
185
|
+
* - `%d` — integer (floors the value, NaN becomes 0)
|
|
186
|
+
* - `%f` — float (NaN becomes 0)
|
|
187
|
+
* - `%%` — literal percent sign
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* luaFormat("Hello %s, you are level %d", "Laot", 42);
|
|
192
|
+
* // → "Hello Laot, you are level 42"
|
|
193
|
+
*
|
|
194
|
+
* luaFormat("Accuracy: %f%%", 99.5);
|
|
195
|
+
* // → "Accuracy: 99.5%"
|
|
196
|
+
*
|
|
197
|
+
* luaFormat("Safe: %s %d", undefined, NaN);
|
|
198
|
+
* // → "Safe: 0"
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
declare function luaFormat(template: string, ...args: FormatArg[]): string;
|
|
202
|
+
/**
|
|
203
|
+
* Creates a `_U(key, fallback, ...args)` translator bound to a locale record.
|
|
204
|
+
*
|
|
205
|
+
* The key supports dot-notation to traverse nested locale objects.
|
|
206
|
+
* If the key isn't found, the fallback string is returned as-is.
|
|
207
|
+
* Any extra args are passed through `luaFormat` for placeholder substitution.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* const _U = createTranslator({
|
|
212
|
+
* locales: {
|
|
213
|
+
* client: {
|
|
214
|
+
* greeting: "Hello %s!",
|
|
215
|
+
* level: "Level %d",
|
|
216
|
+
* },
|
|
217
|
+
* server: {
|
|
218
|
+
* error: "Error: %s",
|
|
219
|
+
* },
|
|
220
|
+
* flat_key: "Plain message",
|
|
221
|
+
* },
|
|
222
|
+
* });
|
|
223
|
+
*
|
|
224
|
+
* _U("client.greeting", "MISSING", "Laot"); // → "Hello Laot!"
|
|
225
|
+
* _U("client.level", "MISSING", 42); // → "Level 42"
|
|
226
|
+
* _U("flat_key", "MISSING"); // → "Plain message"
|
|
227
|
+
* _U("no.key", "Not found"); // → "Not found"
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
declare function createTranslator(options: TranslatorOptions): TranslatorFn;
|
|
231
|
+
/**
|
|
232
|
+
* Deep-merges multiple locale records into one.
|
|
233
|
+
* Later records override earlier ones on key conflicts.
|
|
234
|
+
* Nested objects are merged recursively, not replaced entirely.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```ts
|
|
238
|
+
* const base = { client: { greeting: "Hello %s!", level: "Level %d" } };
|
|
239
|
+
* const overrides = { client: { greeting: "Hey %s!" } };
|
|
240
|
+
*
|
|
241
|
+
* const merged = mergeLocales(base, overrides);
|
|
242
|
+
* // merged.client.greeting → "Hey %s!"
|
|
243
|
+
* // merged.client.level → "Level %d" (preserved from base)
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
declare function mergeLocales(...records: LocaleRecord[]): LocaleRecord;
|
|
247
|
+
|
|
248
|
+
export { type FetchNuiFactoryOptions, type FetchNuiOptions, type FormatArg, type LocaleRecord, type NuiEventMap, type NuiMessageHandler, type NuiMessagePayload, type TranslatorFn, type TranslatorOptions, type UnsubscribeFn, createFetchNui, createTranslator, luaFormat, mergeLocales, onNuiMessage };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
function getResourceName() {
|
|
3
|
+
if (typeof window !== "undefined" && window.GetParentResourceName) {
|
|
4
|
+
return window.GetParentResourceName();
|
|
5
|
+
}
|
|
6
|
+
return "nui-frame-app";
|
|
7
|
+
}
|
|
8
|
+
function createFetchNui(factoryOptions) {
|
|
9
|
+
const debug = factoryOptions?.debug ?? false;
|
|
10
|
+
const mockData = factoryOptions?.mockData;
|
|
11
|
+
return async function fetchNui(event, data, options) {
|
|
12
|
+
if (debug) {
|
|
13
|
+
console.log(`[NUIX] \u2192 ${event}`, data ?? {});
|
|
14
|
+
}
|
|
15
|
+
if (mockData && event in mockData) {
|
|
16
|
+
const mock = mockData[event];
|
|
17
|
+
if (mock === void 0) {
|
|
18
|
+
throw new Error(`[NUIX] Mock data for "${event}" is undefined`);
|
|
19
|
+
}
|
|
20
|
+
const result = typeof mock === "function" ? mock(data) : mock;
|
|
21
|
+
if (debug) {
|
|
22
|
+
console.log(`[NUIX] \u2190 ${event} (mock)`, result);
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
const url = `https://${getResourceName()}/${event}`;
|
|
27
|
+
const controller = options?.timeout ? new AbortController() : void 0;
|
|
28
|
+
let timeoutId;
|
|
29
|
+
if (controller && options?.timeout) {
|
|
30
|
+
timeoutId = setTimeout(() => controller.abort(), options.timeout);
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json; charset=UTF-8" },
|
|
36
|
+
body: JSON.stringify(data ?? {}),
|
|
37
|
+
signal: controller?.signal
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error(`[NUIX] fetchNui("${event}") failed with HTTP ${response.status}`);
|
|
41
|
+
}
|
|
42
|
+
const result = await response.json();
|
|
43
|
+
if (debug) {
|
|
44
|
+
console.log(`[NUIX] \u2190 ${event}`, result);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
49
|
+
throw new Error(`[NUIX] fetchNui("${event}") timed out after ${options?.timeout}ms`);
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
} finally {
|
|
53
|
+
if (timeoutId !== void 0) clearTimeout(timeoutId);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/listener.ts
|
|
59
|
+
function onNuiMessage(action, handler) {
|
|
60
|
+
const listener = (event) => {
|
|
61
|
+
const payload = event.data;
|
|
62
|
+
if (!payload || typeof payload !== "object") return;
|
|
63
|
+
if (payload.action !== action) return;
|
|
64
|
+
handler(payload.data);
|
|
65
|
+
};
|
|
66
|
+
window.addEventListener("message", listener);
|
|
67
|
+
return () => {
|
|
68
|
+
window.removeEventListener("message", listener);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/utils.ts
|
|
73
|
+
function luaFormat(template, ...args) {
|
|
74
|
+
let argIndex = 0;
|
|
75
|
+
return template.replace(/%([sdfi%])/g, (match, specifier) => {
|
|
76
|
+
if (specifier === "%") return "%";
|
|
77
|
+
const raw = args[argIndex++];
|
|
78
|
+
switch (specifier) {
|
|
79
|
+
case "s":
|
|
80
|
+
return String(raw ?? "");
|
|
81
|
+
case "d":
|
|
82
|
+
case "i": {
|
|
83
|
+
const num = Number(raw);
|
|
84
|
+
return String(Number.isNaN(num) ? 0 : Math.floor(num));
|
|
85
|
+
}
|
|
86
|
+
case "f": {
|
|
87
|
+
const num = Number(raw);
|
|
88
|
+
return String(Number.isNaN(num) ? 0 : num);
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return match;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function resolveKey(locales, key) {
|
|
96
|
+
const segments = key.split(".");
|
|
97
|
+
let current = locales;
|
|
98
|
+
for (const segment of segments) {
|
|
99
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
100
|
+
const next = current[segment];
|
|
101
|
+
if (next === void 0) return void 0;
|
|
102
|
+
current = next;
|
|
103
|
+
}
|
|
104
|
+
return typeof current === "string" ? current : void 0;
|
|
105
|
+
}
|
|
106
|
+
function createTranslator(options) {
|
|
107
|
+
const { locales } = options;
|
|
108
|
+
return (key, fallback, ...args) => {
|
|
109
|
+
const template = resolveKey(locales, key);
|
|
110
|
+
if (template === void 0) {
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
return args.length > 0 ? luaFormat(template, ...args) : template;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function mergeLocales(...records) {
|
|
117
|
+
const result = {};
|
|
118
|
+
for (const record of records) {
|
|
119
|
+
for (const [key, value] of Object.entries(record)) {
|
|
120
|
+
const existing = result[key];
|
|
121
|
+
if (typeof value === "object" && typeof existing === "object" && existing !== void 0) {
|
|
122
|
+
result[key] = mergeLocales(existing, value);
|
|
123
|
+
} else {
|
|
124
|
+
result[key] = value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
createFetchNui,
|
|
132
|
+
createTranslator,
|
|
133
|
+
luaFormat,
|
|
134
|
+
mergeLocales,
|
|
135
|
+
onNuiMessage
|
|
136
|
+
};
|
|
137
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts","../src/listener.ts","../src/utils.ts"],"sourcesContent":["import type { NuiEventMap, FetchNuiOptions, FetchNuiFactoryOptions } from \"./types\";\r\n\r\n// ─── Resource Name ───\r\n\r\n/**\r\n * Grabs the resource name from FiveM's injected global.\r\n * Falls back to `\"nui-frame-app\"` when running outside the game (local dev, tests).\r\n */\r\nfunction getResourceName(): string {\r\n\tif (typeof window !== \"undefined\" && window.GetParentResourceName) {\r\n\t\treturn window.GetParentResourceName();\r\n\t}\r\n\treturn \"nui-frame-app\";\r\n}\r\n\r\n// ─── FetchNui Factory ───\r\n\r\n/**\r\n * Creates a fully typed `fetchNui` function tied to your event map.\r\n *\r\n * The returned function POSTs JSON to `https://<resourceName>/<event>`,\r\n * which maps 1:1 to `RegisterNUICallback` on the Lua side.\r\n *\r\n * Supports optional debug logging, mock data for local dev, and per-call timeout.\r\n *\r\n * @example\r\n * ```ts\r\n * interface MyEvents extends NuiEventMap {\r\n * getPlayer: { request: { id: number }; response: { name: string } };\r\n * notify: { request: { msg: string }; response: void };\r\n * }\r\n *\r\n * // Production usage\r\n * const fetchNui = createFetchNui<MyEvents>();\r\n * const player = await fetchNui(\"getPlayer\", { id: 1 });\r\n * // player.name is typed as string\r\n *\r\n * // Local dev with mocks + debug\r\n * const fetchNui = createFetchNui<MyEvents>({\r\n * debug: true,\r\n * mockData: {\r\n * getPlayer: { name: \"DevPlayer\" },\r\n * notify: (req) => { console.log(\"Mock:\", req.msg); },\r\n * },\r\n * });\r\n * ```\r\n *\r\n * Lua side:\r\n * ```lua\r\n * RegisterNUICallback(\"getPlayer\", function(data, cb)\r\n * local player = GetPlayerData(data.id)\r\n * cb({ name = player.name })\r\n * end)\r\n * ```\r\n */\r\nexport function createFetchNui<TMap extends NuiEventMap>(factoryOptions?: FetchNuiFactoryOptions<TMap>) {\r\n\tconst debug = factoryOptions?.debug ?? false;\r\n\tconst mockData = factoryOptions?.mockData;\r\n\r\n\treturn async function fetchNui<K extends keyof TMap & string>(\r\n\t\tevent: K,\r\n\t\tdata?: TMap[K][\"request\"],\r\n\t\toptions?: FetchNuiOptions,\r\n\t): Promise<TMap[K][\"response\"]> {\r\n\t\tif (debug) {\r\n\t\t\tconsole.log(`[NUIX] → ${event}`, data ?? {});\r\n\t\t}\r\n\r\n\t\t// ─── Mock Mode ───\r\n\r\n\t\tif (mockData && event in mockData) {\r\n\t\t\tconst mock = mockData[event];\r\n\r\n\t\t\tif (mock === undefined) {\r\n\t\t\t\tthrow new Error(`[NUIX] Mock data for \"${event}\" is undefined`);\r\n\t\t\t}\r\n\r\n\t\t\tconst result =\r\n\t\t\t\ttypeof mock === \"function\"\r\n\t\t\t\t\t? (mock as (req: TMap[K][\"request\"]) => TMap[K][\"response\"])(data as TMap[K][\"request\"])\r\n\t\t\t\t\t: mock;\r\n\r\n\t\t\tif (debug) {\r\n\t\t\t\tconsole.log(`[NUIX] ← ${event} (mock)`, result);\r\n\t\t\t}\r\n\r\n\t\t\treturn result as TMap[K][\"response\"];\r\n\t\t}\r\n\r\n\t\t// ─── Real Fetch ───\r\n\r\n\t\tconst url = `https://${getResourceName()}/${event}`;\r\n\r\n\t\tconst controller = options?.timeout ? new AbortController() : undefined;\r\n\t\tlet timeoutId: ReturnType<typeof setTimeout> | undefined;\r\n\r\n\t\tif (controller && options?.timeout) {\r\n\t\t\ttimeoutId = setTimeout(() => controller.abort(), options.timeout);\r\n\t\t}\r\n\r\n\t\ttry {\r\n\t\t\tconst response = await fetch(url, {\r\n\t\t\t\tmethod: \"POST\",\r\n\t\t\t\theaders: { \"Content-Type\": \"application/json; charset=UTF-8\" },\r\n\t\t\t\tbody: JSON.stringify(data ?? {}),\r\n\t\t\t\tsignal: controller?.signal,\r\n\t\t\t});\r\n\r\n\t\t\tif (!response.ok) {\r\n\t\t\t\tthrow new Error(`[NUIX] fetchNui(\"${event}\") failed with HTTP ${response.status}`);\r\n\t\t\t}\r\n\r\n\t\t\tconst result = (await response.json()) as TMap[K][\"response\"];\r\n\r\n\t\t\tif (debug) {\r\n\t\t\t\tconsole.log(`[NUIX] ← ${event}`, result);\r\n\t\t\t}\r\n\r\n\t\t\treturn result;\r\n\t\t} catch (error) {\r\n\t\t\tif (error instanceof DOMException && error.name === \"AbortError\") {\r\n\t\t\t\tthrow new Error(`[NUIX] fetchNui(\"${event}\") timed out after ${options?.timeout}ms`);\r\n\t\t\t}\r\n\t\t\tthrow error;\r\n\t\t} finally {\r\n\t\t\tif (timeoutId !== undefined) clearTimeout(timeoutId);\r\n\t\t}\r\n\t};\r\n}\r\n","import type { NuiEventMap, NuiMessagePayload, NuiMessageHandler, UnsubscribeFn } from \"./types\";\r\n\r\n// ─── NUI Message Listener ───\r\n\r\n/**\r\n * Listens for NUI messages from Lua (`SendNUIMessage`), filtered by action name.\r\n * Only messages matching the given `action` trigger the handler.\r\n * Returns a cleanup function to stop listening.\r\n *\r\n * @example\r\n * ```ts\r\n * interface MyMessages extends NuiEventMap {\r\n * showMenu: { request: { items: string[] }; response: void };\r\n * hideMenu: { request: void; response: void };\r\n * }\r\n *\r\n * const unsub = onNuiMessage<MyMessages, \"showMenu\">(\"showMenu\", (data) => {\r\n * console.log(data.items); // typed as string[]\r\n * });\r\n *\r\n * // Stop listening when done\r\n * unsub();\r\n * ```\r\n *\r\n * Lua side:\r\n * ```lua\r\n * SendNUIMessage({ action = \"showMenu\", data = { items = {\"Pistol\", \"Rifle\"} } })\r\n * ```\r\n */\r\nexport function onNuiMessage<TMap extends NuiEventMap, K extends keyof TMap & string>(\r\n\taction: K,\r\n\thandler: NuiMessageHandler<TMap[K][\"request\"]>,\r\n): UnsubscribeFn {\r\n\tconst listener = (event: MessageEvent<NuiMessagePayload<TMap[K][\"request\"]>>) => {\r\n\t\tconst payload = event.data;\r\n\r\n\t\tif (!payload || typeof payload !== \"object\") return;\r\n\t\tif (payload.action !== action) return;\r\n\r\n\t\thandler(payload.data);\r\n\t};\r\n\r\n\twindow.addEventListener(\"message\", listener);\r\n\r\n\treturn () => {\r\n\t\twindow.removeEventListener(\"message\", listener);\r\n\t};\r\n}\r\n","import type { FormatArg, LocaleRecord, TranslatorFn, TranslatorOptions } from \"./types\";\r\n\r\n// ─── Lua-Style Formatter ───\r\n\r\n/**\r\n * Formats a string using Lua-style placeholders.\r\n *\r\n * Specifiers:\r\n * - `%s` — string (null/undefined becomes empty string)\r\n * - `%d` — integer (floors the value, NaN becomes 0)\r\n * - `%f` — float (NaN becomes 0)\r\n * - `%%` — literal percent sign\r\n *\r\n * @example\r\n * ```ts\r\n * luaFormat(\"Hello %s, you are level %d\", \"Laot\", 42);\r\n * // → \"Hello Laot, you are level 42\"\r\n *\r\n * luaFormat(\"Accuracy: %f%%\", 99.5);\r\n * // → \"Accuracy: 99.5%\"\r\n *\r\n * luaFormat(\"Safe: %s %d\", undefined, NaN);\r\n * // → \"Safe: 0\"\r\n * ```\r\n */\r\nexport function luaFormat(template: string, ...args: FormatArg[]): string {\r\n\tlet argIndex = 0;\r\n\r\n\treturn template.replace(/%([sdfi%])/g, (match, specifier: string) => {\r\n\t\tif (specifier === \"%\") return \"%\";\r\n\r\n\t\tconst raw = args[argIndex++];\r\n\r\n\t\tswitch (specifier) {\r\n\t\t\tcase \"s\":\r\n\t\t\t\treturn String(raw ?? \"\");\r\n\r\n\t\t\tcase \"d\":\r\n\t\t\tcase \"i\": {\r\n\t\t\t\tconst num = Number(raw);\r\n\t\t\t\treturn String(Number.isNaN(num) ? 0 : Math.floor(num));\r\n\t\t\t}\r\n\r\n\t\t\tcase \"f\": {\r\n\t\t\t\tconst num = Number(raw);\r\n\t\t\t\treturn String(Number.isNaN(num) ? 0 : num);\r\n\t\t\t}\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn match;\r\n\t\t}\r\n\t});\r\n}\r\n\r\n// ─── Dot-Notation Resolver ───\r\n\r\n/**\r\n * Walks a nested locale object by splitting the key on `.`\r\n * Returns the string value at the end of the path, or `undefined` if any segment is missing.\r\n *\r\n * `\"client.greeting\"` → looks up `locales.client.greeting`\r\n * `\"flat_key\"` → looks up `locales.flat_key` directly\r\n */\r\nfunction resolveKey(locales: LocaleRecord, key: string): string | undefined {\r\n\tconst segments = key.split(\".\");\r\n\tlet current: LocaleRecord | string = locales;\r\n\r\n\tfor (const segment of segments) {\r\n\t\tif (typeof current !== \"object\" || current === null) return undefined;\r\n\t\tconst next: string | LocaleRecord | undefined = current[segment];\r\n\t\tif (next === undefined) return undefined;\r\n\t\tcurrent = next;\r\n\t}\r\n\r\n\treturn typeof current === \"string\" ? current : undefined;\r\n}\r\n\r\n// ─── Translator Factory ───\r\n\r\n/**\r\n * Creates a `_U(key, fallback, ...args)` translator bound to a locale record.\r\n *\r\n * The key supports dot-notation to traverse nested locale objects.\r\n * If the key isn't found, the fallback string is returned as-is.\r\n * Any extra args are passed through `luaFormat` for placeholder substitution.\r\n *\r\n * @example\r\n * ```ts\r\n * const _U = createTranslator({\r\n * locales: {\r\n * client: {\r\n * greeting: \"Hello %s!\",\r\n * level: \"Level %d\",\r\n * },\r\n * server: {\r\n * error: \"Error: %s\",\r\n * },\r\n * flat_key: \"Plain message\",\r\n * },\r\n * });\r\n *\r\n * _U(\"client.greeting\", \"MISSING\", \"Laot\"); // → \"Hello Laot!\"\r\n * _U(\"client.level\", \"MISSING\", 42); // → \"Level 42\"\r\n * _U(\"flat_key\", \"MISSING\"); // → \"Plain message\"\r\n * _U(\"no.key\", \"Not found\"); // → \"Not found\"\r\n * ```\r\n */\r\nexport function createTranslator(options: TranslatorOptions): TranslatorFn {\r\n\tconst { locales } = options;\r\n\r\n\treturn (key: string, fallback: string, ...args: FormatArg[]): string => {\r\n\t\tconst template = resolveKey(locales, key);\r\n\r\n\t\tif (template === undefined) {\r\n\t\t\treturn fallback;\r\n\t\t}\r\n\r\n\t\treturn args.length > 0 ? luaFormat(template, ...args) : template;\r\n\t};\r\n}\r\n\r\n// ─── Deep Merge ───\r\n\r\n/**\r\n * Deep-merges multiple locale records into one.\r\n * Later records override earlier ones on key conflicts.\r\n * Nested objects are merged recursively, not replaced entirely.\r\n *\r\n * @example\r\n * ```ts\r\n * const base = { client: { greeting: \"Hello %s!\", level: \"Level %d\" } };\r\n * const overrides = { client: { greeting: \"Hey %s!\" } };\r\n *\r\n * const merged = mergeLocales(base, overrides);\r\n * // merged.client.greeting → \"Hey %s!\"\r\n * // merged.client.level → \"Level %d\" (preserved from base)\r\n * ```\r\n */\r\nexport function mergeLocales(...records: LocaleRecord[]): LocaleRecord {\r\n\tconst result: LocaleRecord = {};\r\n\r\n\tfor (const record of records) {\r\n\t\tfor (const [key, value] of Object.entries(record)) {\r\n\t\t\tconst existing = result[key];\r\n\r\n\t\t\tif (typeof value === \"object\" && typeof existing === \"object\" && existing !== undefined) {\r\n\t\t\t\tresult[key] = mergeLocales(existing, value);\r\n\t\t\t} else {\r\n\t\t\t\tresult[key] = value;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\treturn result;\r\n}\r\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@laot/nuix",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Modular, type-safe TypeScript library for FiveM NUI projects",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"fivem",
|
|
30
|
+
"nui",
|
|
31
|
+
"typescript",
|
|
32
|
+
"cfx",
|
|
33
|
+
"citizenfx"
|
|
34
|
+
],
|
|
35
|
+
"author": "laot",
|
|
36
|
+
"homepage": "https://github.com/laot7490/nuix#readme",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/laot7490/nuix.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/laot7490/nuix/issues"
|
|
43
|
+
},
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"tsup": "^8.4.0",
|
|
47
|
+
"typescript": "^5.7.3"
|
|
48
|
+
}
|
|
49
|
+
}
|