Skip to content

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.

web/contract.ts
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 most limit calls in any per seconds, 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.

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.nullable is s.optional that also lets TypeScript pass null. Lua has no null. 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.literal key, 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.json is 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.

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 plate
s.string({ pattern: /^#[0-9a-f]{6}$/ }) // a colour
s.string({ max: 24, pattern: /^[a-z0-9_]+$/ }) // a model name

A 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.

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);
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.

nexus/client.lua defines the global Nexus.

Nexus.open('shop', { item = 'water', price = 5 }) -- open, or update the props of an open screen
Nexus.close('shop') -- returns whether anything was closed
Nexus.close() -- closes the screen that has focus
Nexus.isOpen('shop')
Nexus.push('shop:stock', { item = 'water', stock = 2 }) -- a push from client Lua
Nexus.set('hud', { health = 87 }) -- patches the state 'hud'
Nexus.unset('hud', 'armour') -- removes keys from it
Nexus.locale(strings) -- the strings behind t()
Nexus.on('shop:preview', function(data) end) -- a message from nui.client
Nexus.onOpen('shop', function(props) end)
Nexus.onClose('shop', function() end)
local result, problem = Nexus.call('shop:buy', { item = 'water', amount = 2 }) -- a contract call
Nexus.app('phone', { name = 'Shop' }) -- an app in LB Phone, see Surfaces
Nexus.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 hud layer never takes focus. Nothing calls SetNuiFocus by 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, add keep-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.set sends 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 as undefined again, 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 as nui.call. The callback receives result, 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. problem is nil on success and otherwise { code = string, message = string?, details = any }, with the codes of a NuiError.
  • onClose runs however the screen closed: from Lua, from the page, or because the resource stopped. It is the place to undo what onOpen did.
  • Messages from the page are validated against contract.client before 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.

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 player
Nexus.push(-1, 'shop:stock', { item = 'water', stock = 2 }) -- to everyone
Nexus.set(source, 'job', { name = 'police', grade = 2 }) -- a state, for one player
Nexus.set(-1, 'weather', { kind = 'rain' }) -- or for everyone
Nexus.unset(source, 'job', 'grade') -- removes keys

A 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: ...

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.

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.app does 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 with surface="tablet". That screen is the whole app: build its pages as components inside.
  • LB opens and closes it. Nexus.open and Nexus.close refuse the name of an app. Nexus.onOpen and Nexus.onClose run when LB shows and hides it, and Nexus.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.html with the surface in its address, loaded in a frame of the phone or tablet. files in fxmanifest.lua needs nothing extra.
  • An app has no props and no focus of its own. focus, keep-input, close, size and layer cannot stand next to surface.
  • LB sets the root font size of an app from the size the device is drawn at, so rem follows 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. Use rem for what should grow with the device and px for 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.

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 instead
end
Nexus.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.world is 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 6 in 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.world once 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:set replaces them. props are typed and checked by contract.screens like those of any screen.
  • Lua opens and closes it through its display. Nexus.open and Nexus.close refuse the name of a world screen. Nexus.onOpen runs with the props when a display of the screen is created and Nexus.onClose when 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.client messages arrive at the same Nexus.on handlers, 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, close or layer. nui.close() does nothing in it.

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 onKey handlers and on:keydown listeners of the screen, as a keydown event. 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, Delete Erase the selection, or the character before or after the caret.
    Enter Submits the form of an <input>, breaks the line in a <textarea>, presses a focused button or link.
    Tab Moves the focus to the next element that takes it, and from the last to the first.
    arrows, Home, End Move the caret in a field.
    Escape Nothing of its own.

    bind:value and on:input see every edit, and a field keeps to its maxlength.

  • :focus never matches on a display, because the browser never has the focus, and no caret is drawn. The runtime puts data-nexus-focus on 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.

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 onExit runs.

  • 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 give screen whenever you know the prop.

  • How it works. Only the page of the resource can receive the keyboard, so Nexus.operate gives 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.

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.

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:

  1. Identity comes from the server. source is 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.
  2. Unknown calls are dropped. A name that is not in the contract gets no handler, no answer and no log line.
  3. The rate limit is applied, per player and per call. It is a sliding window: at most limit accepted calls in any per seconds. 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.
  4. 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.
  5. 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.
  6. 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.

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.

  • null does not exist in Lua. A key that holds null is an absent key. s.optional and s.nullable therefore accept both null and a missing key, and a value Lua leaves out reaches the page as undefined, never as null. Test with == null.
  • A trailing null in an array disappears: [1, 2, null] arrives as { 1, 2 }. A null in 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.0 is accepted as the integer 1.
  • Paths in messages count from 1, as Lua does: vehicles[2].price: expected an integer.

Set the convar nexus_dev to 1 on a development server:

setr nexus_dev 1

setr 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 errors in the same way.
  • Nexus.push and Nexus.set validate 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.

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.

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
};