Tiny, security-hardened client↔server callback layer with optional latent events for large payloads.
- Safe request/response with promises (sync or async style)
- Latent mode for big transfers (bandwidth-capped, BYTES/sec)
- DoS guards: per-player rate limit + concurrency cap (server)
- No
sourcespoofing (trustedargs.source) - Ticket duplicate protection & TTL cleanup
- Owner verification (only the asked client can resolve)
- Strict event name validation (configurable)
- Helper:
RegisterSecureCallbackwith a validator
-
Put the script in your resource (both client & server).
-
Ensure it’s loaded on each side where you’ll call/register.
-
(Optional) Tweak the Config section at the top:
BANDWIDTH_LIMIT(bytes/sec, latent)MAX_PAYLOAD,CHUNK_TTL_MS, rate limits, concurrencySTRICT_EVENT_NAMES,EVENTNAME_PATTERNdebug(settrueto log)
RegisterCallback("inventory:get", function(args)
-- args.source is trusted: player id on server, -1 on client
return { items = { "bread", "water" } }
end)local data, err = TriggerCallback("inventory:get", {}, 10)
if not data then
print("Failed:", err) -- e.g., "Callback timed out."
else
print(json.encode(data))
endTriggerCallback("inventory:get", {}, 10, function(data, err)
if err then return print("Failed:", err) end
print("Items:", json.encode(data))
end)-- Prefer this helper to avoid putting __playerId in args
TriggerCallbackFor(playerId, "ui:open", { page = "shop" }, 15)-- Use for large payloads (e.g., > 128KB)
TriggerLatentCallback("data:bulk", hugeTable, 60, function(ok, err)
if err then print("bulk err:", err) end
end)Registers a function to handle requests.
handler(args)returns any values; they’ll round-trip back to the caller.args.sourceis injected and cannot be overridden by the caller.
Wraps your handler with a validator.
validator(args)→trueorfalse,"reason"- On
false, the caller receives(nil, "reason").
Sends a request to the other side.
method:"normal"(default) or"latent"- Sync: returns
...on success ornil, "error"on failure/timeout. - Async: calls
asyncCallback(result..., err); on success,errisnil.
Server→client convenience wrapper that never exposes __playerId in user args.
Shorthand for latent mode.
- Rate limit: token bucket (
RATE_TOKENS_PER_SEC,RATE_BURST) - Size cost:
RATE_COST_PER_64KBper 64KB to thwart spammy large requests - Concurrency cap:
MAX_INFLIGHT_PER_PLAYERinflight requests per player - Payload cap:
MAX_PAYLOAD(~10MB) with TTL cleanup - Owner check: responses must come from the same player that was asked
- Duplicate/Replay: tickets tracked; duplicates ignored briefly (
SEEN_TTL_MS)
Note: FiveM latent bandwidth arg is bytes/sec. BANDWIDTH_LIMIT = 1_000_000 ≈ 1 MB/s per target; tune for your player counts.
RegisterSecureCallback("shop:buy",
function(a)
if type(a.item) ~= "string" then return false, "invalid item" end
if type(a.qty) ~= "number" or a.qty < 1 or a.qty > 50 then
return false, "invalid qty"
end
return true
end,
function(a)
local src = a.source
-- do billing/inventory checks here…
return true, "ok"
end
)Client:
local ok, msg = TriggerCallback("shop:buy", { item = "bread", qty = 2 }, 10)
if not ok then print("Purchase failed:", msg) endRegisterCallback("ui:getDashboard", function(a)
return { cash = 12345, jobs = { "miner", "trucker" } }
end)
-- later, from server:
TriggerCallbackFor(src, "ui:getDashboard", {}, 10, function(data, err)
if err then print("dash err:", err) return end
-- send to NUI or client state
end)-- either side
local big = { entries = {} }
for i=1, 500000 do big.entries[i] = { id=i, v=i*2 } end
TriggerLatentCallback("data:sync", big, 90, function(_, err)
if err then print("sync failed:", err) end
end)- Don’t set
args.sourceyourself; it’s injected. - Keep event names simple; with
STRICT_EVENT_NAMESthey must match:[%w%._%-:/]+. - Use validators (
RegisterSecureCallback) for any action that changes state or money. - Prefer latent for anything you expect to exceed ~128KB.
MIT — see header in the source file.