NUI
This page covers typed NUI: the Lua side Fx.nui in a client script, and the page side @rivolabs/fx-nui, a dependency-free TypeScript package for the browser page (React, Svelte, Vue or plain JS). Together they replace raw RegisterNUICallback / SendNUIMessage / fetch: page calls are checked against a Schema, answers carry either a value or a tagged error, and the page can branch on the error's tag the way Lua code uses catchTag. It also covers running the page in a normal browser while you build it.
Wire format
A callback registered with Fx.nui.handle always answers with one of:
{ ok: true, value: unknown }
{ ok: false, error: { _tag: string, ...fields } }
_tag |
When | Fields |
|---|---|---|
NuiInvalidInput |
the body failed spec.input |
name, message (the schema's message) |
your tag (OutOfStock, InsufficientFunds, ...) |
the handler failed with a tagged error, including one from Fx.rpc.call |
the error's fields, serialized with Fx.serializeError (functions dropped) |
NuiInternalError |
the handler raised, died, failed with something that is not a tagged error, or returned a value that failed spec.output |
name only. The cause is logged in the client console with Fx.log.error, never sent to the page |
NuiInterrupted |
the handler's fiber was interrupted (for example on resource stop) | name |
The page side adds two tags of its own: NuiTimeout and NuiTransportError (see fetchNui).
Messages from Fx.nui.send arrive in the page as a window message event whose data is { type, payload }.
Lua side: Fx.nui
All of Fx.nui is client-side. Error classes on Fx.nui: NuiInvalidInput and NuiInvalidMessage. NuiInternalError and NuiInterrupted exist only as tags in callback answers; there are no classes for them. See Errors.
Fx.nui.handle(name, spec, handler)
Fx.nui.handle(name: string, spec: { input: Fx.Schema, output?: Fx.Schema }, handler: fun(input: any): any)
-- name: the callback name the page fetches
-- handler: returns a value or an effect
- Side: client.
- Parameters:
name: registered withRegisterNUICallback(name, ...); the page callsfetchNui(name, body).spec.input: required. Checked against the request body; the handler gets the schema's output (unknown struct keys stripped).spec.output: optional. Checked against the handler's result before it is sent.handler(input): returns a plain value or an effect.
- Returns: nothing. Not an effect; the callback is registered right away.
- Fails with: raises a Lua error at call time when
spec.inputis missing. At request time it never raises; every outcome becomes an answer (see Wire format). - Notes:
- Each request runs on its own fiber, so the handler can suspend (
Fx.rpc.call,Fx.sleep) andcbis called when it ends. The page's fetch is never left hanging, even when the fiber is interrupted. - The handler's fiber has no
Scopeservice. Wrap scoped work inFx.scoped. - An
outputmismatch is treated as a bug in your code: the page getsNuiInternalErrorand the messagenui callback "<name>" returned invalid output: ...is logged.
- Each request runs on its own fiber, so the handler can suspend (
local S = Fx.S
local OutOfStock = Fx.TaggedError('OutOfStock')
Fx.nui.handle('buy', {
input = S.struct { item = S.string, qty = S.int:between(1, 10) },
output = S.struct { item = S.string, qty = S.int, paid = S.int, cash = S.int },
}, function(input)
if input.item == 'gold' then return OutOfStock { item = input.item }:fail() end
-- the server's tagged errors (InsufficientFunds, ...) reach the page as their tag
return Fx.rpc.call('shop:buy', input, { timeout = 5000 })
end)
Fx.nui.send(msgType, payload, schema)
Fx.nui.send(msgType: string, payload?: any, schema?: Fx.Schema) -> Fx.Effect<nil, NuiInvalidMessage>
- Side: client.
- Parameters:
msgType, thetypethe page listens for withonMessage;payload;schema, checked againstpayloadfirst. - Returns:
nilafterSendNUIMessage({ type = msgType, payload = payload }). With a schema, the schema's output is sent. - Fails with:
NuiInvalidMessage { type, message }whenpayloadfailsschema; nothing is sent. - Notes: it is an effect, so it sends nothing until run (
Fx.yield,Fx.runFork).
local Open = S.struct { title = S.string, items = S.array(S.string) }
Fx.yield(Fx.nui.send('open', { title = '24/7 Supermarket', items = { 'water', 'bread', 'phone' } }, Open))
Fx.nui.useFocus(opts)
Fx.nui.useFocus(opts?: { cursor?: boolean, keepInput?: boolean }) -> Fx.Effect<nil, never>
-- requires Scope
- Side: client.
- Parameters:
opts.cursor, show the cursor (defaulttrue);opts.keepInput, also callSetNuiFocusKeepInput(true)so the game keeps receiving input. - Returns:
nil. CallsSetNuiFocus(true, cursor)when it runs. - Fails with: never.
- Notes: when the scope closes, it calls
SetNuiFocus(false, false)(andSetNuiFocusKeepInput(false)ifkeepInputwas set). Focus is released however the scope ends: success, failure, interruption or resource stop.
-- /shop opens the page; the 'close' callback interrupts the fiber, which releases focus
local open
RegisterCommand('shop', function()
if open then return end
open = Fx.runFork(Fx.scoped(Fx.gen(function()
Fx.yield(Fx.nui.useFocus())
Fx.yield(Fx.nui.send('open', { items = { 'water', 'bread', 'phone' } }))
Fx.yield(Fx.never)
end)):ensuring(Fx.sync(function()
open = nil
SendNUIMessage({ type = 'close' })
end)))
end, false)
Fx.nui.handle('close', { input = S.struct {} }, function()
if open then open:interruptAsFork(0) end
return true
end)
Page side: @rivolabs/fx-nui
npm install @rivolabs/fx-nui
The package ships ESM and CJS builds with type declarations. Its exports: fetchNui, FetchNuiOptions (type), NuiError, isNuiError, onMessage, emitMessage, mockNui, isInGame, resourceName, setDevResource. To generate the payload types from the same JSON schema as your Lua validators, see CLI.
fetchNui(name, input, options)
interface FetchNuiOptions {
/** Milliseconds before rejecting with NuiTimeout. Default 10000; 0 waits forever. */
readonly timeout?: number
/** Call another resource's callback (default: this page's resource). */
readonly resource?: string
readonly signal?: AbortSignal
}
function fetchNui<O = unknown, I = unknown>(name: string, input?: I, options?: FetchNuiOptions): Promise<O>
- Side: page.
- Parameters:
name, theFx.nui.handlename;input, the body (sent asJSON.stringify(input ?? {}));options. - Returns: a promise of the handler's
value. - Fails with (rejects with a
NuiError):- the handler's own tag,
NuiInvalidInput,NuiInternalErrororNuiInterrupted, as answered by Lua; NuiInternalError { name }when the answer is{ ok: false }without a taggederror;NuiTimeout { name, timeoutMs }whentimeoutruns out;NuiTransportError { name, status }for a non-2xx HTTP response;NuiTransportError { name, message }whenfetchitself fails, including whenoptions.signalaborts the request.
- the handler's own tag,
- Notes:
- Posts to
https://<resource>/<name>withContent-Type: application/json; charset=UTF-8, where<resource>isoptions.resource ?? resourceName(). - Answers that are not
{ ok: boolean, ... }are returned as they are, so it also works with plainRegisterNUICallbackhandlers. An empty body resolves toundefined; a body that is not JSON resolves to the text. - Outside the game, a mock registered with
mockNui(name, ...)answers instead andfetchis not called. Mocks skip the timeout, the signal and the{ ok }unwrapping, and an error thrown by a mock rejects unchanged (it is not wrapped in aNuiError). - A signal that is already aborted when you call
fetchNuiis not noticed; only an abort during the request cancels it. - The type parameters are not checked at runtime. Validate on the Lua side with
spec.outputif the page relies on the shape.
- Posts to
import { fetchNui, isNuiError } from "@rivolabs/fx-nui"
type Receipt = { item: string; qty: number; paid: number; cash: number }
try {
const r = await fetchNui<Receipt>("buy", { item: "water", qty: 2 }, { timeout: 5000 })
setStatus(`Bought ${r.qty}x ${r.item} for $${r.paid}`)
} catch (e) {
if (isNuiError(e, "InsufficientFunds")) setStatus(`Need $${e.fields.need}, have $${e.fields.have}`)
else if (isNuiError(e, "NuiInvalidInput")) setStatus(`Check the order: ${e.fields.message}`)
else throw e
}
NuiError
class NuiError extends Error {
readonly _tag: string
readonly fields: Readonly<Record<string, unknown>>
constructor(tag: string, fields?: Record<string, unknown>)
}
- Side: page.
- Notes:
nameis"NuiError"._tagis the error's tag.fieldsholds every field sent with the error, plus_tag.messageis"<tag>: <fields.message>"whenfields.messageis a string, otherwise just"<tag>".- Throw one from a
mockNuihandler to simulate a Lua failure.
import { NuiError } from "@rivolabs/fx-nui"
const e = new NuiError("OutOfStock", { item: "gold" })
e._tag // "OutOfStock"
e.fields // { item: "gold", _tag: "OutOfStock" }
e.message // "OutOfStock"
isNuiError(err, tag)
function isNuiError(err: unknown, tag?: string): err is NuiError
- Side: page.
- Returns:
truewhenerris aNuiErrorand, iftagis given, its_tagequalstag. Narrows the type. - Fails with: never.
catch (e) {
if (isNuiError(e, "OutOfStock")) showSoldOut(e.fields.item as string)
}
onMessage(type, handler)
function onMessage<P = unknown>(type: string, handler: (payload: P) => void): () => void
- Side: page.
- Parameters:
type, matched againstevent.data.type;handler, called withevent.data.payload. - Returns: a function that removes the listener.
- Fails with: never. Errors thrown by
handlerare not caught. - Notes: listens to
windowmessageevents, so it receives everyFx.nui.send(type, payload)from the resource and everyemitMessage(type, payload). Messages of other types and non-objectdataare ignored. The payload type is not checked at runtime.
import { onMessage } from "@rivolabs/fx-nui"
const stop = onMessage<{ items: string[] }>("open", ({ items }) => render(items))
// later, e.g. in a React effect cleanup
stop()
emitMessage(type, payload)
function emitMessage<P = unknown>(type: string, payload?: P): void
- Side: page.
- Returns: nothing. Dispatches a
MessageEvent("message", { data: { type, payload } })onwindow, the same shapeFx.nui.sendproduces. - Notes: for running the page outside the game and for tests.
import { emitMessage, isInGame } from "@rivolabs/fx-nui"
if (!isInGame()) emitMessage("open", { items: ["water", "bread", "phone"] })
mockNui(name, handler)
function mockNui<I = unknown, O = unknown>(name: string, handler: (input: I) => O | Promise<O>): () => void
- Side: page.
- Parameters:
name, the callback to answer;handler, gets theinputpassed tofetchNui. - Returns: a function that removes the mock (only if it is still the registered one for
name). - Notes:
- Used only when
isInGame()is false; in game, mocks are ignored. - Return a value (or a promise) to resolve the call. Throw a
NuiErrorto reject it the way Lua would. - One mock per name; registering again replaces it.
- Used only when
import { mockNui, NuiError } from "@rivolabs/fx-nui"
mockNui<{ item: string; qty: number }, { item: string; qty: number; paid: number; cash: number }>("buy", ({ item, qty }) => {
if (qty * 5 > 100) throw new NuiError("InsufficientFunds", { need: qty * 5, have: 100 })
return { item, qty, paid: qty * 5, cash: 100 - qty * 5 }
})
isInGame()
function isInGame(): boolean
- Side: page.
- Returns:
truewhenglobalThis.GetParentResourceNameis a function, which FiveM's CEF injects into NUI pages.
if (!isInGame()) document.body.style.background = "#333" // no game behind the page in a browser
resourceName()
function resourceName(): string
- Side: page.
- Returns:
GetParentResourceName()in game; outside the game, the name set withsetDevResource(default"nui-dev").
console.log(`page for ${resourceName()}`)
setDevResource(name)
function setDevResource(name: string): void
- Side: page.
- Notes: sets the name
resourceName()returns outside the game, and so the hostfetchNuiposts to (https://<name>/<callback>) when no mock answers. Has no effect in game.
setDevResource("my_shop")
Developing the page in a normal browser
Outside FiveM there is no GetParentResourceName, so isInGame() is false. Then:
- Register a
mockNuifor every callback the page calls. Without a mock,fetchNuiposts tohttps://nui-dev/<name>(or thesetDevResourcename), which normally fails withNuiTransportError. - Use
emitMessageto play the messages the Lua side would send withFx.nui.send. - Keep the mocks in the shipped page if you like: they are ignored in game.
// main.ts
import { emitMessage, isInGame, mockNui, NuiError } from "@rivolabs/fx-nui"
if (!isInGame()) {
let cash = 100
mockNui<{ item: string; qty: number }>("buy", ({ item, qty }) => {
const paid = qty * 5
if (paid > cash) throw new NuiError("InsufficientFunds", { need: paid, have: cash })
cash -= paid
return { item, qty, paid, cash }
})
mockNui("close", () => true)
emitMessage("open", { items: ["water", "bread", "phone"] })
}
Then run any dev server (or open index.html directly, as the lua-nui template does) and work on the page with the browser's devtools. npx rivo-fx create my_shop --template lua-nui scaffolds a resource with a page wired this way (CLI).