EndCore Framework

Callbacks

Request and response between server and client with encore.callback, including async handlers, timeouts, return values and how to validate what clients send.

Callbacks let one side ask the other a question and wait for the answer. The client asks the server for a trader's stock, or the server asks a client what it can see. encore.callback handles the request ids, routing and timeouts, so a player who disconnects mid-request can never hang your code.

API

encore.callback is a table you can also call directly as a function for the async form.

Server

FunctionReturnsNotes
encore.callback.register(name, handler)nonehandler(source, ...) returns the answer
encore.callback.registerAsync(name, handler)nonehandler(source, respond, ...) answers later by calling respond(...)
encore.callback.await(name, playerId, ...)the client's return valuesAsks one client. Yields until it answers or times out
encore.callback(name, playerId, cb, ...)noneAsks one client without yielding. cb(...) receives the answer

Client

FunctionReturnsNotes
encore.callback.register(name, handler)nonehandler(...) returns the answer
encore.callback.registerAsync(name, handler)nonehandler(respond, ...) answers later by calling respond(...)
encore.callback.await(name, ...)the server's return valuesYields until the server answers or times out
encore.callback(name, cb, ...)noneDoesn't yield. cb(...) receives the answer

Settings

FieldDefaultMeaning
encore.callback.timeout15000Milliseconds before a request gives up. Set it in your own resource; it only affects that resource

Return values

  • Handlers can return several values, and all of them arrive in order.
  • nil values in the middle are kept, so return nil, 'Not enough scrap' arrives as nil, 'Not enough scrap'.
  • Values cross the network, so return plain data: strings, numbers, booleans and tables of those. Entity handles differ between machines; send network ids instead.

Timeouts and errors

  • A timed-out await returns nothing, so every result is nil.
  • A timed-out async call runs cb with no arguments.
  • If a handler throws, the error is logged as Callback "<name>" errored: ... and the caller receives nothing straight away, without waiting for the timeout.
  • A registerAsync handler that never calls respond is answered with nothing when the timeout passes. Only the first respond counts.

Always handle the nil case:

lua
local stock = encore.callback.await('my-shop:getStock', shopId)
if not stock then
    return encore.notify({ description = 'The trader is not answering.', type = 'error' })
end

Naming

Every resource shares one callback namespace. Prefix names with your resource: 'my-shop:getStock', 'en-zombies:findSpawnPoints'.

Security

Danger

A server callback runs for any client that calls it, with any arguments that client chooses. source is the only value you can trust. Validate every argument before you use it.

Check:

  • Types: type(shopId) == 'string', math.type(amount) == 'integer'.
  • Ranges: amounts above zero and below a sensible maximum.
  • Existence: the shop, item or vehicle actually exists.
  • Permission: the player has the job, group, key or item needed.
  • Distance: the player is standing where the action makes sense.

The library does protect the transport. When the server asks a client, it only accepts an answer from the player it asked, so one client can't inject an answer into another player's request. But the player you asked still controls what they send back. Treat the results of a server-side await as untrusted too.

Examples

Client asks the server

lua
-- server/main.lua
encore.callback.register('my-shop:getStock', function(source, shopId)
    if type(shopId) ~= 'string' then return nil end

    local shop = Shops[shopId]
    if not shop then return nil end

    local ped = GetPlayerPed(source)
    if #(GetEntityCoords(ped) - shop.coords) > 10.0 then return nil end

    return shop.stock, os.time()
end)

-- client/main.lua
local stock, stamp = encore.callback.await('my-shop:getStock', 'north_camp')
if not stock then
    return encore.notify({ description = 'Shop unavailable', type = 'error' })
end

Returning a reason on failure

lua
-- server
encore.callback.register('my-crafting:craft', function(source, recipeId)
    local recipe = type(recipeId) == 'string' and Recipes[recipeId]
    if not recipe then return false, 'Unknown recipe' end

    for item, count in pairs(recipe.cost) do
        if encore.inventory.getItemCount(source, item) < count then
            return false, 'Missing materials'
        end
    end

    if not encore.inventory.canCarry(source, recipe.result, 1) then
        return false, 'No room in your pack'
    end

    for item, count in pairs(recipe.cost) do
        encore.inventory.removeItem(source, item, count)
    end
    encore.inventory.addItem(source, recipe.result, 1)
    return true
end)

-- client
local ok, reason = encore.callback.await('my-crafting:craft', 'makeshift_knife')
encore.notify({ description = ok and 'Crafted' or reason or 'No answer', type = ok and 'success' or 'error' })

Server asks a client

lua
-- client
encore.callback.register('my-base:getGroundZ', function(x, y)
    local found, z = GetGroundZFor_3dCoord(x + 0.0, y + 0.0, 1000.0, false)
    return found and z or nil
end)

-- server: the answer comes from the player, so sanity-check it
local z = encore.callback.await('my-base:getGroundZ', source, coords.x, coords.y)
if type(z) ~= 'number' or math.abs(z - coords.z) > 50.0 then
    z = coords.z
end

Answering later with registerAsync

Use registerAsync when the answer arrives in another callback, which is how QBCore and ESX scripts are usually written.

lua
-- server
encore.callback.registerAsync('my-stash:load', function(source, respond, stashId)
    if type(stashId) ~= 'string' then return respond(nil) end

    MySQL.query('SELECT items FROM my_stashes WHERE id = ?', { stashId }, function(rows)
        respond(rows[1] and json.decode(rows[1].items) or {})
    end)
end)

Async form

lua
-- client: don't block this thread
encore.callback('my-shop:getStock', function(stock)
    if stock then renderStock(stock) end
end, 'north_camp')

How it works

For reference only, you never need to trigger these yourself. Requests travel on the net event __enc_cb:req:<name>, and answers come back on __enc_cb:res:<resource>, carrying the asking resource and a numeric request id. Requests whose resource isn't a string or whose id isn't a number are ignored.