Custom App RPC
A custom app is an iframe inside RoadPhone. It has noui_page of its own,
which decides everything on this page: how a request reaches your server, and
why the answer can only ever come back as a reply to a request the UI made
itself.
Every request follows the same shape:
roadphone:customApp:<resource> — with the
handler name as a string argument. Do not register one callback per action.
Picking a transport
There are two ways for the UI to reach your client, and both end at the same server callback.JavaScript side
Oneapi() helper for the whole app. Everything else calls through it.
Errors are codes, not sentences
The server returns a code, the UI turns it into a translated string. That keeps player-facing text in your locale files and out of Lua.Normalise empty lists
An empty Lua table serialises as{}, not []. Anything you iterate needs a
guard:
Lua: client relay
The client does nothing but forward — unless the action is genuinely client-only (setting a waypoint, playing an animation), in which case it never has to leave the client at all.Lua: server dispatcher
One callback, a handler table, and a guard thatcb fires exactly once.
CreateThread is what lets a handler wait on a database query. Register the
callback through whichever system your framework uses:
- ESX —
ESX.RegisterServerCallback(name, fn) - QBCore —
QBCore.Functions.CreateCallback(name, fn) - Qbox —
lib.callback.register(name, fn)(needs@ox_lib/init.lua)
Refetching: there is no push
SendNUIMessage from your resource has no document to deliver to and cannot
reach the iframe. Nothing your server does can put data into the UI on its own —
the UI has to ask. Four patterns cover it:
1
Refetch when a view opens
The default, and it covers more than it looks like. Leaving the app and
coming back destroys and reloads the iframe — your boot code runs again
from scratch, so there is nothing stale to refresh. Within a session, have
every tab or view load its own data on mount, so switching away and back is
a refresh. Ask the server for feature flags before drawing the tab bar, so
no tab points at a disabled system.
2
Cache with a TTL, bust it on writes
For read-heavy apps, cache per handler name and drop the whole cache after
any write — a write makes every cached read suspect. Never cache a reply
that carries an
error, and exclude handlers that must always be live.3
Notify, then let the app refetch
Server-side events reach the player through RoadPhone’s own notification
system, not through your iframe:When the client already knows what happened, use the client export instead
and skip the round trip:
exports['roadphone']:sendNotification({ … }).The UI can listen for it and refresh:4
Time out locally on a server-provided duration
For “this expires in N seconds”, have the server return the duration and run
the countdown in the UI. Guard it with a token so a stale timer from an
earlier run cannot overwrite a newer state.
Effects in the game world are not affected by any of this. Blips, waypoints,
animations and props are plain client code:
TriggerClientEvent from your
server to a RegisterNetEvent in your client works normally, because it never
touches NUI.Host events worth listening to
appOpened fires for the app the player navigates to. Your own iframe is
torn down when the player leaves your app, so use phoneOpened — not
appOpened — as the “I am visible again” signal.Checklist
- One server callback per resource, dispatching by handler name.
cbis called on every branch, exactly once — early returns included.cbis resolved inside the NUI callback, never from a later net event.- The UI unwraps
roadphone.post()replies before reading any field. - Every fetch has a timeout; a dead server callback must not become a blank screen.
- Empty Lua tables are normalised to arrays in JS.
datafrom the UI is validated server-side.- Views refetch on open; writes bust the cache.
- No
SendNUIMessagein a resource without aui_page.