The bridge
The bridge carries messages between the page and Lua. Every message is declared once, in
web/contract.ts. From that file the toolchain derives the TypeScript types of nui, the Lua
validators that run on the server, and the checks of the browser mock, so the three cannot drift
apart.
The contract
Section titled “The contract”import { contract, s } from 'nexus/contract';
export default contract({ // Page to server, with an answer. Validated on the server before the handler runs. calls: { 'shop:buy': { input: s.object({ item: s.string({ max: 40 }), amount: s.int({ min: 1, max: 100 }) }), output: s.object({ ok: s.boolean(), balance: s.int() }), rate: { limit: 5, per: 10 }, }, }, // Server or client Lua to page. pushes: { 'shop:stock': s.object({ item: s.string({ max: 40 }), stock: s.int({ min: 0 }) }), }, // Page to client Lua, nothing comes back. For what is not authority: previews, cameras. client: { 'shop:preview': s.object({ item: s.string({ max: 40 }) }), }, // Named objects that client Lua patches and the page reads reactively. state: { hud: s.object({ health: s.int(), armour: s.int(), cash: s.int() }), }, // What Lua opens a screen with. Types `props` in web/screens/Shop.nexus. screens: { shop: s.object({ item: s.string({ max: 40 }), price: s.int({ min: 0 }) }), },});| Section | Direction | Lua side | Page side |
|---|---|---|---|
calls |
page to server and back | Nexus.handle(name, fn) on the server |
await nui.call(name, input) |
pushes |
Lua to page | Nexus.push(...) on either side |
nui.on(name, fn) |
client |
page to client Lua | Nexus.on(name, fn) on the client |
nui.client(name, data) |
state |
Lua to page | Nexus.set(...) on either side |
nui.state(name) |
screens |
client Lua to page | Nexus.open(name, props) on the client, Nexus.world for a world screen |
props of the screen |
A call has four optional parts:
input: what the page sends. Leave it out for a call that takes nothing:nui.call('shop:list').output: what the handler returns. Leave it out for a call that returns nothing.rate: at mostlimitcalls in anyperseconds, counted per player and per call. Without it the limit is 30 calls per 10 seconds.errors: what a refusal carries, by code. See Refusals.
A name is 1 to 64 characters from letters, digits and _ : . - /. A state and the props of a
screen must be an s.object: a state because it is patched key by key, props because they are
read by name.
screens is optional, screen by screen. A screen it names has its props typed from the schema:
the script needs no interface Props, and one it writes anyway must accept what the contract
declares. ScreenProps<'shop'> from nexus is that type, for a helper that takes the props. A
screen the contract does not name is typed by its own interface Props, as before.
contract() checks the definition when the file loads and throws a ContractError that names
the place, for example calls["shop:buy"].rate.limit must be a whole number from 1 to 10000, got 0.
nexus build, nexus check and nexus dev print it.
Schemas
Section titled “Schemas”Every schema is strict. An object accepts exactly its keys, and every string, array and record has a maximum size, set by you or by default. A payload that passed validation therefore has a bounded size, whatever the sender intended.
| Schema | Accepts | TypeScript type |
|---|---|---|
s.string({ min, max, pattern }) |
Text of min to max characters. Default max: 1024. |
string |
s.int({ min, max }) |
A whole number in range. Without a range, any integer a JavaScript number holds exactly. | number |
s.number({ min, max }) |
A finite number. Never NaN or infinity. | number |
s.boolean() |
true or false |
boolean |
s.enum(['car', 'bike']) |
One of the listed strings | 'car' | 'bike' |
s.literal('yes') |
Exactly that string, number or boolean | 'yes' |
s.array(item, { min, max }) |
A list of min to max items. Default max: 256. |
T[] |
s.object({ key: schema }) |
An object with exactly these keys | { key: T } |
s.record(value, { max }) |
An object used as a map: text keys of at most 64 characters, at most max entries. Default: 256. |
Record<string, T> |
s.union(a, b, ...) |
A value that one of the schemas accepts | A | B |
s.json({ maxBytes, maxDepth }) |
Any JSON value within a size and a depth. Defaults: 4096 bytes, 8 deep. | unknown |
s.optional(schema) |
The value, or nothing | T | undefined, and key?: T in an object |
s.nullable(schema) |
The value, null, or nothing |
T | null | undefined |
Details that matter:
-
Length is counted in characters, not bytes, on both sides:
'日本語'has length 3. -
Items of an array and values of a record cannot be optional or nullable. A Lua table cannot hold
nil, so the gap would be lost on the way. Wrap the array itself instead. -
s.nullableiss.optionalthat also lets TypeScript passnull. Lua has nonull. See Where JSON and Lua differ. -
A union is tried member by member, and the value passes when one accepts it. Its members cannot be optional or nullable: wrap the union instead. For objects that differ by a tag, give each a
s.literalkey, which also lets TypeScript narrow on it:s.union(s.object({ kind: s.literal('cash'), amount: s.int({ min: 1 }) }),s.object({ kind: s.literal('item'), name: s.string({ max: 40 }) }),) -
s.jsonis for data whose shape the resource does not own, such as the metadata of an inventory item. It is the one schema that is not strict about shape, so it is bounded another way: by what the value would weigh as JSON and by how deep it nests. The weight is counted the same on both sides and is close to the length of the JSON text, not equal to it: a string costs its bytes plus 2, a number 8, a boolean 4, a list or object 2 plus 1 per entry, and a key its bytes plus 2. Read what you take out of it as carefully as any unknown value.
Patterns
Section titled “Patterns”pattern takes a regular expression that the whole string must match. Because the same check has
to give the same answer in JavaScript and in Lua, only a subset is allowed, and s.string throws
for anything outside it:
- The pattern starts with
^and ends with$. - It is a sequence of ASCII characters,
\d,\w, escaped punctuation such as\., and classes such as[a-z0-9_ ]. Ranges in a class run between letters or digits. - Each of those may be followed by
?,*,+,{n},{n,}or{n,m}. - Not allowed: flags,
., groups, alternatives with|, negated classes such as[^a], other escapes such as\s, and characters outside ASCII.
s.string({ max: 8, pattern: /^[A-Z0-9 ]{1,8}$/ }) // a number plates.string({ pattern: /^#[0-9a-f]{6}$/ }) // a colours.string({ max: 24, pattern: /^[a-z0-9_]+$/ }) // a model nameA string that contains a character outside ASCII never matches a pattern. For free text, such as
a chat message, use min and max alone. For a fixed list of values use s.enum.
In Lua a pattern is not run by Lua’s own pattern matcher. The generated code walks the text once per part of the pattern, so the time it takes depends on the length of the text and nothing else. There is no input that makes it slow.
What is generated
Section titled “What is generated”| File | Written by | Contents |
|---|---|---|
web/nexus-contract.d.ts |
nexus dev, nexus build, nexus check |
Fills in the NexusContract interface of the runtime, which types nui |
nexus/contract.lua |
nexus build, nexus dev --game |
One validator per message and the rate limit of every call, as plain Lua |
nexus/screens.lua |
nexus build, nexus dev --game |
Focus, Escape and surface of every screen, from the <screen> tags |
nexus/client.lua, nexus/server.lua |
nexus build, nexus dev --game |
The two Lua runtimes, copied from the package |
The four Lua files depend on nothing: no framework, no library. fxmanifest.lua loads them like
this, before your own scripts:
lua54 'yes'
ui_page 'web/dist/index.html'files { 'web/dist/index.html', 'web/dist/**/*' }
shared_scripts { 'nexus/contract.lua' }client_scripts { 'nexus/screens.lua', 'nexus/client.lua', 'client/main.lua' }server_scripts { 'nexus/server.lua', 'server/main.lua' }nexus build checks these lines and prints the ones that are missing.
The generators are also available from code:
import { generate } from '@nexusstudios/ui/contract';
const { types, lua } = generate(contract);In the page: nui
Section titled “In the page: nui”import { nui, NuiError } from 'nexus';nui.call(name, input, { timeout }) |
Calls a server handler. Resolves with the output, rejects with a NuiError. The default timeout is 10 seconds. |
nui.pending(name) |
A read-only signal: a call of that name is waiting for its answer. |
nui.on(name, handler) |
Listens to a push until the component is removed. Returns a function that stops listening. |
nui.client(name, data) |
Sends a message to client Lua. Nothing comes back. |
nui.state(name) |
A reactive object that mirrors what Lua sets with Nexus.set. |
nui.close() |
Asks Lua to close the screen that has focus. |
All of them are typed by the contract: a wrong name, a missing key or a misspelt field of the
answer is a type error in the editor and in nexus check.
A failed call rejects with a NuiError. Its code is one of:
| Code | Meaning |
|---|---|
invalid |
The input did not pass the contract, or the call is not in it. message says what was wrong, for example amount: expected at least 1. |
rate_limited |
The player made this call too often. |
rejected |
The handler raised an error, the call has no handler, or (in dev mode) the answer did not match the contract. The server console has the reason. |
timeout |
No answer arrived in time. |
offline |
There is nothing to answer: the page runs in a browser without a mock handler for this call. |
| anything else | A code the handler returned with Nexus.reject(code, details). details holds what it passed along. |
try { const { balance } = await nui.call('shop:buy', { item: 'water', amount: 2 });} catch (error) { if (error instanceof NuiError && error.code === 'not_enough_money') showHint();}ScreenProps<'shop'> is the type of the props the contract declares for a screen.
In client Lua
Section titled “In client Lua”nexus/client.lua defines the global Nexus.
Nexus.open('shop', { item = 'water', price = 5 }) -- open, or update the props of an open screenNexus.close('shop') -- returns whether anything was closedNexus.close() -- closes the screen that has focusNexus.isOpen('shop')
Nexus.push('shop:stock', { item = 'water', stock = 2 }) -- a push from client LuaNexus.set('hud', { health = 87 }) -- patches the state 'hud'Nexus.unset('hud', 'armour') -- removes keys from itNexus.locale(strings) -- the strings behind t()
Nexus.on('shop:preview', function(data) end) -- a message from nui.clientNexus.onOpen('shop', function(props) end)Nexus.onClose('shop', function() end)
local result, problem = Nexus.call('shop:buy', { item = 'water', amount = 2 }) -- a contract callNexus.app('phone', { name = 'Shop' }) -- an app in LB Phone, see SurfacesNexus.world('clock', { txd = 'my_clock', texture = 'my_clock_face' }) -- a screen on a prop, see World screens- Focus is automatic. The screen opened last that asks for focus has it. When it closes,
focus goes back to the one before it, or to the game. A screen on the
hudlayer never takes focus. Nothing callsSetNuiFocusby hand. focus="mouse"shows the cursor. FiveM cannot give a page the mouse without the keyboard, so the page also receives key events. To let the player keep moving while a screen is open, addkeep-input. While such a screen shows the cursor, the runtime disables the camera and attack controls, so that moving the mouse does not turn the view and a click does not fire a weapon.- Escape is handled by the page: a screen with
close="escape"asks Lua to close it. The runtime keeps the pause menu shut for a quarter of a second afterwards, because the key is usually still down when the game gets its input back. Nexus.setsends only the keys whose value changed. Calling it ten times a second with the same values sends nothing. A table is compared by its content, key by key and item by item, so the same list built again is the same value. Before the page has loaded, the state is kept and sent when it is ready.Nexus.unset(name, key, ...)removes keys. In the page they read asundefinedagain, which is how a state starts. A key that is not there sends nothing.Nexus.call(name, data, callback)makes a contract call from client Lua, for what does not start in a page: a target option, an item, a command. It goes through the same validation, rate limit and server handler asnui.call. The callback receivesresult, problem. Without a callback it waits for the answer and returns the same two values, so it has to run in a thread or an event handler.problemisnilon success and otherwise{ code = string, message = string?, details = any }, with the codes of aNuiError.onCloseruns however the screen closed: from Lua, from the page, or because the resource stopped. It is the place to undo whatonOpendid.- Messages from the page are validated against
contract.clientbefore your handler runs. One that does not match is dropped. - A name that is not in the contract, or a screen that does not exist, raises an error at the line that used it.
The runtime starts no thread while it is idle. One runs while a keep-input screen has focus,
one for a quarter of a second after a screen is closed from the page, and one while a display is
operated.
When the resource stops, the onClose handlers of open screens run, focus is released and
every display is destroyed.
In server Lua
Section titled “In server Lua”nexus/server.lua defines the global Nexus.
Nexus.handle('shop:buy', function(source, data) -- data has passed the contract: a table with item and amount, and nothing else. local price = prices[data.item] if not price then return Nexus.reject('unknown_item') end if not removeMoney(source, price * data.amount) then return Nexus.reject('not_enough_money') end return { ok = true, balance = getMoney(source) }end)
Nexus.push(source, 'shop:stock', { item = 'water', stock = 2 }) -- to one playerNexus.push(-1, 'shop:stock', { item = 'water', stock = 2 }) -- to everyone
Nexus.set(source, 'job', { name = 'police', grade = 2 }) -- a state, for one playerNexus.set(-1, 'weather', { kind = 'rain' }) -- or for everyoneNexus.unset(source, 'job', 'grade') -- removes keysA handler may wait (Wait, a database query). A call has one handler. The handler of a call
without output returns nothing: in dev mode, anything it returns is refused like any other
answer that does not match.
Nexus.set on the server reaches the player’s client, which applies the patch as its own
Nexus.set would: it compares, and only what changed reaches the page. The server keeps no copy
of a state, so it does not know what a player’s page shows. A player who joins later has not
received what was set for everyone before: set it again for that player when they join.
When a handler raises an error, the call is answered with rejected and the server console
names the call and the player:
[nexus] the handler of 'shop:buy' raised an error for player 3: server/main.lua:12: ...Refusals
Section titled “Refusals”Nexus.reject(code) refuses a call. The code is a string of 1 to 64 characters, and the page
gets it as the code of a NuiError.
A refusal can carry data. Declare it under errors, by code:
'shop:buy': { input: s.object({ item: s.string({ max: 40 }), amount: s.int({ min: 1, max: 100 }) }), output: s.object({ ok: s.boolean(), balance: s.int() }), errors: { not_enough_money: s.object({ missing: s.int({ min: 1 }) }), out_of_stock: s.object({ left: s.int({ min: 0 }) }), },},return Nexus.reject('not_enough_money', { missing = price - balance })import { nui, NuiError, type NexusContract } from 'nexus';
type BuyErrors = NexusContract['calls']['shop:buy']['errors'];
try { await nui.call('shop:buy', { item: 'water', amount: 2 });} catch (error) { if (error instanceof NuiError && error.code === 'not_enough_money') { const { missing } = error.details as BuyErrors['not_enough_money']; }}error.details is unknown, because a call can also end in timeout or invalid, which carry
nothing. The generated NexusContract has the declared types, as above.
A code that errors does not list can still be returned, without details. In dev mode the
server checks details against the schema as it checks an answer, and refuses details for a code
that declares none. Nexus.call in client Lua gets them as problem.details.
Surfaces
Section titled “Surfaces”A surface is somewhere a screen is shown other than the resource’s own page. There are two: an app in LB Phone and an app in LB Tablet. A screen names its surface in its tag, and client Lua registers the app:
<screen surface="phone" />Nexus.app('phone', { name = 'Garage', description = 'Your vehicles', icon = 'web/dist/app-icon.png', defaultApp = true,})| Field | |
|---|---|
name |
The name under the icon. Required. |
icon |
A file the resource ships, as a path from its root. The runtime turns it into what each LB expects. |
identifier |
What LB knows the app by. Default: the name of the resource. |
fixBlur |
LB Phone only. Default true: the frame is drawn at its real size, with a font size that scales with the phone. |
| anything else | Passed to LB’s AddCustomApp as it is: description, developer, defaultApp, size, price and so on. |
The runtime sets ui, onOpen and onClose itself. What LB does with the other fields is in
its own documentation.
- Registration needs no care. The app is added when LB is running, added again when LB
restarts, and removed when the resource stops. If LB is not on the server,
Nexus.appdoes nothing and logs nothing. If LB refuses the app, the client console has the reason LB gave. - One screen per surface. A project has at most one screen with
surface="phone"and one withsurface="tablet". That screen is the whole app: build its pages as components inside. - LB opens and closes it.
Nexus.openandNexus.closerefuse the name of an app.Nexus.onOpenandNexus.onCloserun when LB shows and hides it, andNexus.isOpen(name)says whether it is showing. - Calls are answered to the page that asked. Pushes, state and the locale go to the main page and to every app that is showing. An app that was hidden is brought up to date when it comes back.
- The same build serves all of them. The app is the resource’s
web/dist/index.htmlwith the surface in its address, loaded in a frame of the phone or tablet.filesinfxmanifest.luaneeds nothing extra. - An app has no props and no focus of its own.
focus,keep-input,close,sizeandlayercannot stand next tosurface. - LB sets the root font size of an app from the size the device is drawn at, so
remfollows the device: about 15px in the phone and 18.5px in the tablet at the sizes of the dev frames. The same component is therefore larger in the tablet. Useremfor what should grow with the device andpxfor what should not. - LB Tablet removes the frame of an app that is not in front, so a tablet app starts again each time it is opened. LB Phone keeps an app it has put in the background. Keep what must survive in state or on the server, not in the component.
An app that LB Phone kept in the background is not loaded again when the player returns to it, and the page is not told. To load fresh data on every return, let Lua say so through a state:
Nexus.onOpen('garageApp', function() Nexus.set('app', { shown = GetGameTimer() })end)const app = nui.state('app');
effect(() => { app.shown; // run again each time LB shows the app untrack(() => void load());});The same goes for an app that is put away: its frame may stay, with its timers running. Stop
them from Nexus.onClose the same way, with a state the page watches.
nexus check and nexus build report an app screen that reads props, a surface that no Lua
registers, and a Nexus.app for a surface no screen declares. See
nexus check.
A third surface, world, is not an app: it has a section of its own.
World screens
Section titled “World screens”A world screen is drawn on a texture in the game world instead of over the game: the screen of a
monitor, a time clock, a kiosk. FiveM calls the mechanism DUI: an off-screen browser whose
picture becomes a texture, which replaces a texture of a model. The screen is a file in
web/screens like any other, with the surface and the size of that browser in its tag:
<screen surface="world" size="1280x720" />Client Lua creates a display of it:
local display, problem = Nexus.world('clock', { txd = 'my_time_clock', -- the texture dictionary: for a prop, the name of its model texture = 'my_time_clock_face', -- the texture in it that is the screen props = { business = 'police' }, -- optional})if not display then -- problem is 'unavailable', 'limit' or 'taken': open an ordinary screen insteadendNexus.world(name, options) |
Creates a display of the world screen name. Returns it, or nil, reason. |
display:set(props) |
Replaces the props of its screen. |
display:destroy() |
Puts the original texture back and frees the browser. |
display:alive() |
Whether the display still exists. |
display:pointer(x, y) |
Moves the pointer. x and y run from 0 to 1 across and down the screen. |
display:press(button), display:release(button) |
A mouse button, 'left', 'right' or 'middle'. A press and a release in one place are a click. |
display:scroll(lines) |
Turns the wheel. Positive is down. |
display:type(text) |
Types text at the caret of the field that has the focus. |
display:key(key) |
Presses Backspace, Delete, Enter, Tab, Escape, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Home or End. |
Nexus.operate(display, options) |
Lets the player use the display with the mouse and the keyboard. |
Nexus.release() |
Ends that. |
Nexus.world never raises an error for a display that cannot be made. It returns nil and why:
| Reason | Meaning |
|---|---|
unavailable |
The game gave no browser. |
limit |
The resource already has as many displays as it may. |
taken |
A display of this resource already draws on that texture. |
- A display is a browser. Nothing is created until
Nexus.worldis called, and each display costs memory and a share of every frame for as long as it exists. Create one for a player who is close enough to see it, and destroy it when they leave. A resource has at most 4 displays at a time.setr nexus_world_limit 6in the server configuration raises that for every resource. - The model has to be loaded. FiveM replaces a texture only if it finds it at that moment, so
call
Nexus.worldonce the prop exists. A replacement does not outlive its model being unloaded either: a display that is kept while the player is far away may come back showing the prop’s own texture. - Every copy of the model shows the same picture, on this client only. A texture is replaced, not one object, and nothing is synchronised: other players see the texture the prop ships with, which is therefore its idle face. To show them a live screen, create a display on their clients too.
- The screen is opened with its props, as soon as its page has loaded, and
display:setreplaces them.propsare typed and checked bycontract.screenslike those of any screen. - Lua opens and closes it through its display.
Nexus.openandNexus.closerefuse the name of a world screen.Nexus.onOpenruns with the props when a display of the screen is created andNexus.onClosewhen one is destroyed.Nexus.isOpen(name)says whether one exists. - Calls are answered to the display that asked. Pushes, state and the locale reach every
display, as they reach the main page and the apps.
nui.clientmessages arrive at the sameNexus.onhandlers, whichever page sent them. - Every display is destroyed when the resource stops, with its texture put back.
- A world screen takes no
focus,keep-input,closeorlayer.nui.close()does nothing in it.
Input on a display
Section titled “Input on a display”The game can put a mouse into the browser of a display and nothing else. It has no cursor, no keyboard and never the input focus. The runtime fills those in:
-
The page draws its own cursor, an arrow where the pointer was last sent, and puts it away after three seconds without the mouse. It is the element
[data-nexus-cursor], for a screen that wants another look. -
Text and keys arrive as messages and are turned into what the keyboard would have done. A key first reaches the
onKeyhandlers andon:keydownlisteners of the screen, as akeydownevent. If none of them takes it:Key Does a character Goes into the field that has the focus, at its caret. Space presses a focused button or ticks a checkbox. Backspace,DeleteErase the selection, or the character before or after the caret. EnterSubmits the form of an <input>, breaks the line in a<textarea>, presses a focused button or link.TabMoves the focus to the next element that takes it, and from the last to the first. arrows, Home,EndMove the caret in a field. EscapeNothing of its own. bind:valueandon:inputsee every edit, and a field keeps to itsmaxlength. -
:focusnever matches on a display, because the browser never has the focus, and no caret is drawn. The runtime putsdata-nexus-focuson the element that has the focus instead. Style it wherever you style:focus:input:focus, input[data-nexus-focus] { border-color: #c8ff3d; }
What a display cannot do: a double click (every click is a single one), a drag that depends on
event.buttons (it reads 0), the list of a <select> (build the choice from buttons), dead keys
and input methods that compose a character from several keys.
Operating a display
Section titled “Operating a display”Nexus.operate(display, { entity = prop, -- the camera goes to face it camera = { offset = vec3(0.0, -0.75, 0.42), fov = 38.0 }, -- relative to the entity onExit = function() end,})Nexus.operate hands the display to the player: the mouse moves its pointer, the buttons and the
wheel work on it, and what is typed and pasted goes to it. The HUD is hidden and the game’s
controls are off while it lasts.
| Option | |
|---|---|
entity |
With it, a scripted camera moves in front of the entity. Without it the view stays as it is. |
camera.offset |
Where the camera stands, relative to the entity. Default: 0.8 in front of its origin, on its negative Y axis. |
camera.target |
What the camera looks at, relative to the entity. Default: straight ahead along the entity’s Y axis, which is head on to a screen that faces the camera’s side. |
camera.fov |
The field of view. Default: 40. |
screen |
Where the screen is on the entity: center relative to the entity, and width and height in metres, for a screen that stands upright across the entity’s X axis. With it, the game’s cursor points at the screen itself. Needs entity. |
onExit |
Runs when it ends, however it ends. |
-
With a camera, the player does not see their own character while it lasts, because the camera stands about where they do. Everyone else still sees them standing at the prop.
-
It ends on Escape, when W, A, S or D is held for a little over half a second, when Lua calls
Nexus.release(), when the display is destroyed, and when a screen that takes the focus is opened: that screen is what the player uses from then on. -
It returns whether it started. It does not start for a display that is gone, before the page of the resource has loaded, or while a screen has the focus.
-
One display is operated at a time. Operating a second lets go of the first, whose
onExitruns. -
With
screen, the game’s cursor is the pointer. The mouse acts on what the cursor is over on the prop, wherever the camera stands, and only while it is over the screen. The display draws no cursor of its own.Nexus.operate(display, {entity = prop,camera = { offset = vec3(0.0, 0.6, 1.32), target = vec3(0.0, 0.0, 1.32), fov = 45.0 },screen = { center = vec3(0.0, -0.12, 1.32), width = 0.70, height = 0.39 },}) -
Without
screen, the pointer covers the screen as the mouse covers the window. The left edge of the game window is the left edge of the screen, whatever the camera shows around it, and the display draws its own cursor where the clicks land. Unless the screen fills the view exactly, that cursor and the game’s are in different places, so givescreenwhenever you know the prop. -
How it works. Only the page of the resource can receive the keyboard, so
Nexus.operategives it the input focus and the page forwards the mouse and the keys to Lua, which passes them to the display. The focus is taken once the page has said that it is forwarding, so a page that did not load cannot leave the player without a way out. The screens of the page hear none of what is forwarded.
The runtime starts no thread for a display. One runs while a display is operated.
Proving it in game
Section titled “Proving it in game”examples/world is a resource that proves each link of this chain inside the game. /worldtest
puts a prop in front of you, creates a display on it and prints PASS or FAIL for what Lua can
observe: the browser, the page reaching Lua, Lua reaching the page, and the pointer, a click, the
wheel, text and a key arriving. The screen on the prop shows the same list, so a screenshot
holds the result. /worldtest use operates it, and /worldtest <model> <txd> <texture> runs the
test on any model. See the roadmap for what is not yet proved.
Security model
Section titled “Security model”A player controls their own client completely. They can open the developer tools of the page,
call fetch on the NUI callback, or trigger the server event directly from an injected script
with any Lua value. The server runtime assumes all of that.
For every call that arrives, in this order:
- Identity comes from the server.
sourceis the one FiveM attached to the event. Nothing in the payload says who is calling, and an event that did not come from a player is ignored. - Unknown calls are dropped. A name that is not in the contract gets no handler, no answer and no log line.
- The rate limit is applied, per player and per call. It is a sliding window: at most
limitaccepted calls in anyperseconds. Calls with invalid input count too, so validation cannot be flooded. Refused calls do not count, so hammering does not extend the wait. A player’s windows are freed when they leave. - The input is validated before the handler sees it: types, ranges, lengths, patterns, unknown keys. Validation stops at the first problem, and it stops counting the entries of a table as soon as there is one too many, so a huge payload is refused without being walked. A table that carries a metatable (which is how a function reference arrives) is refused.
- The handler runs isolated. An error in it is caught and logged with the call name, the
player and a stack trace, the caller gets
rejected, and the next call works as usual. - The answer goes to the caller only.
What the bridge does not do is decide whether a player is allowed to do something. The contract
says a model is a short lowercase string. Whether that model is for sale, whether this player
owns it and whether they can pay is for the handler to decide, from what the server knows. Never
accept a price, a balance or a player id from the page: send the item, and look the rest up.
The client runtime validates too: contract.client messages before your handlers run, and the
input of a call before it is sent, so an honest page gets an immediate invalid or
rate_limited without a trip to the server. The server relies on none of it.
In production, refused input is not logged, so that a flood cannot fill the console. The caller still receives the reason.
{@html} in a component inserts raw HTML. Text typed by players must never go through it.
Where JSON and Lua differ
Section titled “Where JSON and Lua differ”A value crosses the bridge as JSON and is then a Lua table. A few things do not survive that
trip, and the contract is defined by what Lua actually receives. The browser mock applies the
same rules, so what works in nexus dev works in game.
nulldoes not exist in Lua. A key that holdsnullis an absent key.s.optionalands.nullabletherefore accept bothnulland a missing key, and a value Lua leaves out reaches the page asundefined, never asnull. Test with== null.- A trailing
nullin an array disappears:[1, 2, null]arrives as{ 1, 2 }. Anullin the middle leaves a gap and is refused. - An empty array and an empty object are the same thing in Lua, an empty table, and FiveM
encodes an empty table as
[]. An empty table from Lua therefore reaches the page as[], also where the contract says object or record. Iterating it and reading keys from it behave as for an empty object. - Integers are limited to what a JavaScript number holds exactly, between -(2^53 - 1) and
2^53 - 1, so a value means the same on both sides.
1.0is accepted as the integer 1. - Paths in messages count from 1, as Lua does:
vehicles[2].price: expected an integer.
Dev mode
Section titled “Dev mode”Set the convar nexus_dev to 1 on a development server:
setr nexus_dev 1setr makes it visible to clients as well. With it:
- The server validates what handlers return. An answer that does not match the contract is
logged and the caller gets
rejected. - The details of a refusal are checked against
errorsin the same way. Nexus.pushandNexus.setvalidate what they are given, on both sides, and raise an error at the line that sent the wrong data.- Refused input and dropped page messages are logged with the reason.
The props of Nexus.open are checked in the page, not in Lua, and only under nexus dev: a
screen opened with props that do not match contract.screens still opens, and the browser
console says what was wrong.
Leave it off in production: output validation costs time on every call, and a bug in a handler’s answer should not take a working feature away from players.
The mock
Section titled “The mock”web/mock.ts stands in for both Lua sides while the page runs in a browser under nexus dev.
import { mock, reject } from 'nexus/contract';import locale from '../locales/en.json';import contract from './contract';
export default mock(contract, { // The strings behind t(). locale, // The props each screen is opened with from the toolbar. screens: { shop: { item: 'water', price: 5, stock: 3 }, }, // The props each world screen is shown with, as Nexus.world would give them. worlds: { clock: { props: { business: 'police' } }, }, // The first value of each state. state: { hud: { health: 100, armour: 0, cash: 500 }, }, // Stand-ins for the server handlers. Typed by the contract. calls: { 'shop:buy': async (input, { push }) => { if (input.amount > 3) return reject('out_of_stock', { left: 3 }); push('shop:stock', { item: input.item, stock: 3 - input.amount }); return { ok: true, balance: 120 }; }, }, // Stand-ins for the Nexus.on handlers of client Lua. client: { 'shop:preview': (data) => console.info('preview', data.item), }, // Runs once when the page loads: for what Lua does on its own, such as a HUD loop. setup({ open, set, action }) { open('hud'); setInterval(() => set('hud', { health: Math.round(Math.random() * 100) }), 1000); action('Take damage', () => set('hud', { health: 12 })); },});Handlers and setup receive a context with push(name, data), set(name, patch),
unset(name, key, ...), open(screen, props) and close(screen), which do what the functions
of the same name do in client Lua, and action(label, run), which adds a button to the dev
toolbar and returns the function that removes it. Use it for what only the game would trigger: a
push from another player, a state that changes on its own.
reject(code, details) is the mock’s Nexus.reject.
The mock enforces the contract exactly as the Lua side does, with the same codes and the same
messages: unknown calls, invalid input, the rate limit, and answers and refusal details that do
not match (always checked, as in dev mode). The props under screens are checked against
contract.screens. A call without a handler is answered with offline.
A screen with a surface is shown in a frame over the page, by its own button in the toolbar:
an app in a frame the size of a phone or a tablet, a world screen in a frame of its size.
Hiding the frame is what LB closing the app, or Lua destroying the display, is in game. In the
frame of a world screen the keyboard is sent as the type and key messages a display gets in
game, so typing takes the same path in the browser as on a prop.
Wire format
Section titled “Wire format”This is private to the runtimes and may change between versions. It is documented so that the traffic you see in the developer tools makes sense.
Page to Lua: a POST to the NUI callback https://<resource>/nexus with a JSON body. Lua
answers the request at once with {}. The result of a call comes back later as a message.
| Body | Meaning |
|---|---|
{ t: 'ready' } |
The page has loaded. Lua answers with the locale, the states and the open screens. |
{ t: 'call', id, name, data } |
A call. id is a number chosen by the page. |
{ t: 'client', name, data } |
A message for client Lua. |
{ t: 'close', screen? } |
Close this screen, or the one that has focus. |
An app adds surface: 'phone' or surface: 'tablet' to each of these and posts to the same
callback, so that Lua answers the frame that asked. An app cannot send close.
A display is the page loaded as
web/dist/index.html?surface=world&screen=<name>&display=<id>&resource=<name>. It adds
surface: 'world' and display, the id from its address, to each message, and cannot send
close either.
While a display is operated, the page of the resource also sends what the player does:
| Body | Meaning |
|---|---|
{ t: 'input', kind: 'ready' } |
The page forwards input from now on. |
{ t: 'input', kind: 'pointer', x, y } |
The mouse moved. x and y run from 0 to 1 across the window. |
{ t: 'input', kind: 'press' | 'release', button, x, y } |
A mouse button. |
{ t: 'input', kind: 'scroll', lines } |
The wheel. |
{ t: 'input', kind: 'type', text } |
A character was typed, or text was pasted. |
{ t: 'input', kind: 'key', key } |
A key that is not a character. |
{ t: 'input', kind: 'leave' } |
A walking key was held. |
Lua to page: SendNUIMessage, received as a message event. Every message has __nexus: 1.
| Message | Meaning |
|---|---|
{ t: 'open', screen, props? } |
Open the screen, or update its props. props is left out when it is empty. |
{ t: 'close', screen } |
Close the screen. |
{ t: 'push', name, data } |
A push. |
{ t: 'state', name, data?, removed? } |
The changed keys of a state, and the names of the keys taken out with Nexus.unset. |
{ t: 'locale', data } |
The strings behind t(). |
{ t: 'res', id, ok: true, data } |
The answer to a call. |
{ t: 'res', id, ok: false, code, message?, details? } |
A failed call. |
An app receives the same messages through LB’s SendCustomAppMessage, which LB forwards to the
frame with postMessage. open and close are never sent to an app.
A display receives them through SendDuiMessage: open for its one screen, once its page is
ready and again when its props are replaced, and two messages of its own:
| Message | Meaning |
|---|---|
{ t: 'type', text } |
Type this text into the field that has the focus. |
{ t: 'key', key } |
Press this key. |
The page of the resource is told to forward input, and to stop, with { t: 'operate', on }.
Client to server: the event <resource>:nexus:call with id, name, data. Server to
client: <resource>:nexus:res with id, ok, data or id, false, code, message, details,
sent to the caller only, <resource>:nexus:push with name, data, and <resource>:nexus:state
with name, patch or name, nil, keys.
In a browser there is no FiveM. nexus dev installs window.__NEXUS_HOST__ before the
page’s scripts run, and the page talks to it instead:
window.__NEXUS_HOST__ = { post(message): Promise<unknown>, // receives what the page would POST onMessage(listener): void, // the page registers its receiver for Lua's messages resource: string, action(label, run): () => void, // adds a button to the dev toolbar};