Skip to content

feat(scripting): add native callback system for request/response patterns#3871

Open
frostfire575 wants to merge 3 commits intocitizenfx:masterfrom
frostfire575:feat/native-callback-system
Open

feat(scripting): add native callback system for request/response patterns#3871
frostfire575 wants to merge 3 commits intocitizenfx:masterfrom
frostfire575:feat/native-callback-system

Conversation

@frostfire575
Copy link
Copy Markdown

@frostfire575 frostfire575 commented Mar 17, 2026

Goal of this PR

Every FiveM framework out there (ox_lib, QB-Core, ESX) ships its own callback system because the platform doesn't have one. The pattern is always the same — pair two events with a request ID and a promise to get a response back from the server. This means every script that needs to fetch data from the server depends on a third-party library just to do something as basic as "ask the server and get an answer."

Right now, if you want to request data from the server and get a response back, you either pull in ox_lib, QB-Core, or roll your own wrapper around paired events. That's a lot of overhead for something as fundamental as "ask the server a question." TriggerServerEvent exists natively for fire-and-forget — this PR gives the platform its missing counterpart for request/response.

This PR adds native callback functions directly into the Lua and JS scripting runtimes, so developers can do request/response communication the same way they use TriggerServerEvent today — without installing anything extra.

How is this PR achieving the goal

The implementation lives entirely in the scripting layer (scheduler.lua for Lua, main.js for JS) — no C++ changes needed. It uses the existing event system, promise/deferred infrastructure, and coroutine threading that's already built into the runtimes.

Under the hood, two internal events handle the protocol:

  • __cfx_cb:req / __cfx_cb:resp for network callbacks (client↔server)

Each callback name is automatically namespaced by resource (e.g. RegisterServerCallback('getPrice', ...) in resource shop becomes shop:getPrice internally). This prevents name collisions between resources without requiring developers to manually prefix everything.

The API provides two calling styles:

  • Await (ServerCallbackAwait) — yields the current coroutine until the response comes back, similar to how Citizen.Await works
  • Non-await (ServerCallback) — fires the request and handles the response in a separate coroutine via a callback function

Registration supports an optional options table:

  • { restricted = true } — only the same resource can call this callback
  • { delay = 2000 } — throttle calls to prevent spam (minimum ms between invocations)

Why this is safe:

  • Server→client calls validate the player exists before sending (DoesPlayerExist)
  • Response spoofing is prevented by validating the source on server-side pending callbacks
  • Timeout is configurable via cb_timeout ConVar (default 10 seconds) — no dangling promises
  • Handler errors are caught with xpcall/try-catch and propagated back to the caller cleanly
  • Duplicate callback names on the same resource just overwrite the previous handler (last registration wins, no silent conflicts)
  • Throttle prevents clients from spamming expensive server callbacks

Usage / API Reference

Client → Server (Lua)

server.lua — Register a callback on the server:

-- Basic registration
RegisterServerCallback('getPlayerMoney', function(source)
    return GetPlayerMoney(source)
end)

-- With options: restricted to this resource only, throttled to 1 call per 2 seconds
RegisterServerCallback('getSecretData', function(source)
    return { balance = 50000, rank = 'admin' }
end, { restricted = true, delay = 2000 })

client.lua — Call from the client:

-- Await style: yields current thread until server responds
local money = ServerCallbackAwait('getPlayerMoney')
print('My money: ' .. money)

-- Non-await style: response handled in separate thread
ServerCallback('getPlayerMoney', function(money)
    print('My money: ' .. money)
end)

Server → Client (Lua)

client.lua — Register a callback on the client:

RegisterClientCallback('getPosition', function()
    return GetEntityCoords(PlayerPedId())
end)

server.lua — Call from the server:

-- Await style
local pos = ClientCallbackAwait(playerId, 'getPosition')
print(('Player is at %.1f, %.1f, %.1f'):format(pos.x, pos.y, pos.z))

-- Non-await style
ClientCallback(playerId, 'getPosition', function(pos)
    print(('Player is at %.1f, %.1f, %.1f'):format(pos.x, pos.y, pos.z))
end)

Multiple Return Values (Lua)

-- server.lua
RegisterServerCallback('calculate', function(source, a, b)
    return a + b, a * b, 'calculated'
end)

-- client.lua
local sum, product, label = ServerCallbackAwait('calculate', 3, 7)
print(sum, product, label)  -- 10  21  calculated

JavaScript (client.js / server.js)

// server.js — Register
RegisterServerCallback('getTime', (source) => {
    return { time: new Date().toISOString(), player: GetPlayerName(source) };
}, { delay: 1000 });

// client.js — Await style (returns Promise)
const { time, player } = await ServerCallbackAwait('getTime');
console.log(`Server time: ${time}, Player: ${player}`);

// client.js — Non-await style
ServerCallback('getTime', (result) => {
    console.log(`Server time: ${result.time}`);
});

API Summary

Direction Register Await Non-await
Client → Server RegisterServerCallback(name, handler, opts?) ServerCallbackAwait(name, ...) ServerCallback(name, cb, ...)
Server → Client RegisterClientCallback(name, handler, opts?) ClientCallbackAwait(playerId, name, ...) ClientCallback(playerId, name, cb, ...)

Options table (optional, passed as 3rd argument to Register functions):

Key Type Default Description
restricted boolean false Only the owning resource can call this callback
delay number false Minimum ms between calls (throttle)

ConVar:

  • cb_timeout — Timeout in ms before a pending callback is rejected (default: 10000)

This PR applies to the following area(s)

FiveM, ScRT: Lua, ScRT: JS

Successfully tested on

Game builds: 3258

Platforms: Windows

What was tested and results:

Every direction was tested across two separate resources (cb-test-a, cb-test-b) to verify both same-resource and cross-resource behavior, plus a dedicated JS resource for runtime interop:

  • Client→Server callbacks (await and non-await) — working
  • Server→Client callbacks (await and non-await) — working
  • Cross-resource calls using resourceName:callbackName syntax — working
  • Restricted callback enforcement (same resource allowed, cross-resource blocked) — working
  • Delay/throttle enforcement (rapid calls correctly rejected with error) — working
  • Error propagation (handler throws → caller receives the error message) — working
  • Timeout on non-existent callbacks (rejects after cb_timeout ms) — working
  • Multiple return values — working
  • JS runtime (all directions) — working
  • Lua↔JS cross-runtime interop (JS client calling Lua server callback and vice versa) — working

Performance benchmark (local server, single player):

Sequential (ServerCallbackAwait):
  Ping @ every tick:      93 req |  18.6 req/s | avg  53.8ms
  getData @ every tick:  100 req |  19.8 req/s | avg  50.4ms
  heavyData @ every tick: 98 req |  19.4 req/s | avg  51.5ms

Concurrent flood (100x ServerCallback, non-await):
  All 100 fired in 0ms, all responses back in 102ms
  Throughput: 980.4 req/s | avg 24.6ms latency

The ~50ms sequential latency is the inherent round-trip through FiveM's event system, not overhead from the callback layer. Payload size has negligible impact. Concurrent non-await calls show the system handles high throughput without issues.

Checklist

  • Code compiles and has been tested successfully.
  • Code explains itself well and/or is documented.
  • My commit message explains what the changes do and what they are for.
  • No extra compilation warnings are added by these changes.

Fixes issues

No existing feature request issues found — this addresses a long-standing community need where every major framework (ox_lib, QB-Core, ESX) independently reimplements the same callback pattern because the platform lacks one natively.

…erns

Adds built-in callback functions to Lua and JS runtimes, eliminating the
need for third-party libraries (ox_lib, QB-Core) for request/response
patterns across client/server boundaries.

Supports all four directions:
- Client→Server: ServerCallback / ServerCallbackAwait
- Server→Client: ClientCallback / ClientCallbackAwait
- Same-side cross-resource: LocalCallback / LocalCallbackAwait

Features:
- Auto-namespacing by resource name to prevent conflicts
- Optional restriction to same-resource only
- Optional delay/throttle per callback
- Configurable timeout via 'cb_timeout' ConVar (default 10s)
- Player existence validation for server→client calls
- Full Lua↔JS interoperability
@github-actions github-actions Bot added ScRT: JS Issues/PRs related to the JavaScript scripting runtime ScRT: Lua Issues/PRs related to the Lua scripting runtime triage Needs a preliminary assessment to determine the urgency and required action labels Mar 17, 2026
@Yum1x
Copy link
Copy Markdown
Contributor

Yum1x commented Mar 18, 2026

If the goal is to standardize RPC natively, a cleaner alternative might be extending the existing exports system to seamlessly handle network calls (e.g., exports.server.resourceName...) rather than injecting a new set of globals into the runtimes.

Honestly, as someone building on this ecosystem, this implementation presents a few core issues:

  • Timing and Motivation: The community solved the request/response pattern in userland a long time ago, and did it efficiently. Pulling this directly into the engine adds unnecessary maintenance overhead to Cfx.re for a problem that doesn't really exist anymore.
  • Global API Conflicts: The proposed naming conventions (RegisterServerCallback, etc.) are basically identical to what major frameworks (ESX, QBCore) and heavily used libraries like ox_lib already inject into the global scope.
  • Redundancy with exports: This is probably the biggest architectural flaw. Adding RegisterLocalCallback for same-side, cross-resource communication competes directly with exports.
  • Missing C# Support: Last but not least, the C# runtime is entirely ignored. Setting aside the structural flaws already mentioned, pushing a feature intended to be a 'native standard' without supporting all official environments just fragments the ecosystem further.

@frostfire575
Copy link
Copy Markdown
Author

If the goal is to standardize RPC natively, a cleaner alternative might be extending the existing exports system to seamlessly handle network calls (e.g., exports.server.resourceName...) rather than injecting a new set of globals into the runtimes.

Honestly, as someone building on this ecosystem, this implementation presents a few core issues:

* **Timing and Motivation:** The community solved the request/response pattern in userland a long time ago, and did it efficiently. Pulling this directly into the engine adds unnecessary maintenance overhead to Cfx.re for a problem that doesn't really exist anymore.

* **Global API Conflicts:** The proposed naming conventions (`RegisterServerCallback`, etc.) are basically identical to what major frameworks (ESX, QBCore) and heavily used libraries like `ox_lib` already inject into the global scope.

* **Redundancy with `exports`:** This is probably the biggest architectural flaw. Adding `RegisterLocalCallback` for same-side, cross-resource communication competes directly with `exports`.

* **Missing C# Support**: Last but not least, the C# runtime is entirely ignored. Setting aside the structural flaws already mentioned, pushing a feature intended to be a 'native standard' without supporting all official environments just fragments the ecosystem further.

appreciate the feedback, but genuinely curious what do you think is the better move here? drop the whole idea or
actually improve it?

because the way i see it:

  • local callbacks yeah you're right, that's just exports with extra steps. i'll drop those completely, no argument
    there.
  • namespace conflicts — this isn't really a problem tbh. qbcore uses QBCore.Functions.TriggerCallback, ox_lib uses
    lib.callback none of them register bare globals like RegisterServerCallback. but to be safe i can namespace
    everything under citizen.* like citizen.RegisterCallback so there's zero chance of collision.
  • maintenance — this is like ~300 lines in the scripting layer, no c++ touches. it's not some massive engine change
    that needs constant upkeep. and the alternative is what? every dev who wants to make a simple standalone script has to
    pull in ox_lib or qbcore just to do a basic server request? that's a dependency headache for something that should
    just work out of the box.
  • c# support — fair point, i can look into adding that. lua and js cover most devs right now but c# shouldn't be left
    out if this is meant to be a platform feature.

so the plan is: drop local callbacks, namespace under citizen.* or keep it as it is, and add c# support. what you think?

@frostfire575 frostfire575 marked this pull request as draft March 18, 2026 08:39
@Yum1x
Copy link
Copy Markdown
Contributor

Yum1x commented Mar 18, 2026

so the plan is: drop local callbacks, namespace under citizen.* or keep it as it is, and add c# support. what you think?

Fair point on the namespace stuff. I haven't messed with those frameworks in a minute, so I was probably a bit off on how they expose their callbacks nowadays

The problem you're trying to solve (standalone scripts needing native RPC) is 100% valid, but the approach needs a pivot rather than just tweaking the current implementation. If Cfx.re is gonna take this on, a true engine-level integration makes way more sense than maintaining a built-in wrapper. Alternatively, maye just ship it as an official default resource (like chat or spawnmanager) instead of baking it straight into the global API

Also, it'd be cool to get some more eyes on this to see what the rest of the community thinks

@frostfire575
Copy link
Copy Markdown
Author

Removed local callbacks .

Wip -> Doing c# callback support.....

@frostfire575 frostfire575 marked this pull request as ready for review April 15, 2026 10:13
@github-actions github-actions Bot added invalid Requires changes before it's considered valid and can be (re)triaged and removed triage Needs a preliminary assessment to determine the urgency and required action labels Apr 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

invalid Requires changes before it's considered valid and can be (re)triaged ScRT: JS Issues/PRs related to the JavaScript scripting runtime ScRT: Lua Issues/PRs related to the Lua scripting runtime

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants