Moving a resource to Rivo FX

You do not have to rewrite anything up front. Rivo FX runs next to plain
Citizen code, Fx.runAwait works from any thread, and the ox_lib bridge keeps
old callers working while you move handlers over. Pick the pattern that hurts
most and start there.

Every "after" below assumes the resource loads Rivo FX:

dependency 'rivo_fx'
shared_script '@rivo_fx/init.lua'

Net events you validate by hand

Before: every handler re-checks its arguments (or forgets to), and nothing stops
a client from firing it 500 times a second.

RegisterNetEvent('shop:buy', function(item, qty)
  local src = source
  if type(item) ~= 'string' or type(qty) ~= 'number' or qty < 1 or qty > 10 then return end
  -- ...
end)

After: the schema runs first, the handler only sees clean input, each player is
rate limited, and rejected payloads are reported to Fx.security.on.

local S = Fx.S
Fx.net.handle('shop:buy', S.struct { item = S.string:maxChars(32), qty = S.int:between(1, 10) },
  function(src, input)
    -- input.item and input.qty are checked; unknown fields are dropped
  end, { rateLimit = '5/s' })

Clients send a table: TriggerServerEvent('shop:buy', { item = 'water', qty = 2 })
or Fx.net.send('shop:buy', { ... }).

ox_lib callbacks

Before:

-- server
lib.callback.register('bank:balance', function(src, account) return getBalance(src, account) end)
-- client
local balance = lib.callback.await('bank:balance', false, 'main')

After: register the handler with Rivo FX and keep libCallback = true until
every caller is moved. Old lib.callback.await callers keep working (still
validated and rate limited), new callers get typed errors and timeouts.

-- server
Fx.rpc.register('bank:balance', { input = S.string, libCallback = true }, function(src, account)
  return Bank.balance(src, account) -- a value or an effect
end)
-- client, new style
local balance = Fx.yield(Fx.rpc.call('bank:balance', 'main', { timeout = 5000 }))

Calling a callback that still lives in ox_lib, from Rivo FX:
Fx.compat.libCallback('other:thing', arg1, arg2, Fx.compat.opts { timeout = 3000 }).

ESX and QBCore callbacks

ESX.RegisterServerCallback and QBCore.Functions.CreateCallback hand you a
cb that the client waits on forever if the server errors. Replace them with
Fx.rpc.register; a failure reaches the caller as its tag, and the caller's
timeout always fires.

-- before
QBCore.Functions.CreateCallback('garage:spawn', function(src, cb, plate)
  local veh = spawn(plate)
  cb(veh ~= nil)
end)

-- after
local NotYours = Fx.TaggedError('NotYours')
Fx.rpc.register('garage:spawn', { input = S.string:maxChars(8) }, function(src, plate)
  if not owns(src, plate) then return NotYours { plate = plate }:fail() end
  return spawn(plate)
end)

-- client
Fx.rpc.call('garage:spawn', plate, { timeout = 5000 })
  :catchTag('NotYours', function() return Fx.succeed(nil) end)

Money and jobs through the framework

Before: one code path per framework, and a failed removal is a false you have
to remember to check.

local Player = QBCore.Functions.GetPlayer(src)
if not Player.Functions.RemoveMoney('bank', 500, 'garage') then return end

After: one service for ESX, QBCore, Qbox and standalone. removeMoney fails
with InsufficientFunds { need, have } and never goes negative.

Fx.setRuntime(Fx.Runtime.make(Fx.framework.auto))

local fw = Fx.yield(Fx.framework.Framework)
Fx.yield(fw.removeMoney(src, 'bank', 500, 'garage'))
fw.onPlayerLoaded(function(src) ... end)   -- fires once per character, on every framework

oxmysql

Before: MySQL.query.await returns rows of whatever shape the table has today,
and a failed query is a printed error and a nil.

local rows = MySQL.query.await('SELECT id, money FROM players WHERE id = ?', { id })

After: rows are checked against a schema, and every failure is an SqlError
you can catch, retry or map.

local Row = S.struct { id = S.int, money = S.int }
local rows = Fx.yield(Fx.db.query('SELECT id, money FROM players WHERE id = ?', { id }, { row = Row })
  :retry(Fx.Schedule.exponential(100):take(3)))

Loading models and dictionaries

-- before
RequestModel(model)
while not HasModelLoaded(model) do Wait(0) end   -- forever, if the model is wrong

-- after
local hash = Fx.yield(Fx.assets.model('adder', { timeout = 5000 }))   -- AssetNotFound / AssetTimeout
-- or released automatically when the scope closes:
Fx.yield(Fx.assets.useModel('adder'))

Threads and loops

-- before: runs forever, even after you no longer need it
CreateThread(function()
  while true do
    Wait(0)
    if #(GetEntityCoords(PlayerPedId()) - shop) < 10.0 then DrawMarker(...) end
  end
end)

-- after: checks distance every 500 ms while away, runs every frame only when
-- close, and stops when the fiber is interrupted or its scope closes
local marker = Fx.runFork(Fx.tick.whileNear(shop, 10.0, function() DrawMarker(...) end))
marker:interruptAsFork(0) -- when you are done

Repeating work: Fx.yield(Fx.sleep(1000)) inside a fiber instead of Wait.
A sleeping fiber costs nothing, and it can be interrupted while it sleeps.

NUI

Before: RegisterNUICallback answers whatever you hand cb, and the page's
fetch never learns about an error.

After, in the client script:

Fx.nui.handle('buy', { input = S.struct { item = S.string, qty = S.int:between(1, 10) } }, function(input)
  return Fx.rpc.call('shop:buy', input)   -- the server's tagged errors reach the page
end)

and in the page, with @rivolabs/fx-nui:

try {
  const receipt = await fetchNui("buy", { item, qty })
} catch (e) {
  if (isNuiError(e, "InsufficientFunds")) showBroke(e.fields.need)
}

Keeping shapes in sync

Once a payload is shared by Lua, a JS resource and a page, describe it once and
generate all three with rivo-fx gen.

Calling Rivo FX from old code

Anything that is still a plain thread can use an effect directly:

CreateThread(function()
  local ok, hash = pcall(Fx.runAwait, Fx.assets.model('adder', { timeout = 5000 }))
end)

View Rivo FX