A small, secure, Promise-based RPC library for FiveM TypeScript and JavaScript resources.
Call code in any context — server, client, or browser (NUI) — and get the result back as a plain Promise. No manual event wiring, no response-routing boilerplate, no missed callbacks.
- Installation
- How It Works
- Quick Start
- API Reference
- TypeScript: Typed Procedures
- Options
- Handler Context
- Rate Limiting
- Payload Validation
- Security
- Configuration Convars
- Error Handling
- Limits
Go to the Releases page and download the latest YabosFiveMRPC.zip.
Extract it — you will get a folder called YabosFiveMRPC.
Place the YabosFiveMRPC folder inside your server's resources/ directory and add this line to your server.cfg:
ensure YabosFiveMRPC
In your own resource's fxmanifest.lua, add:
dependency 'YabosFiveMRPC'Server-side — import directly in your compiled/bundled server script:
import { registerRpc, callClient } from 'path/to/YabosFiveMRPC/server/server.js';Client-side — import directly in your compiled/bundled client script:
import { registerRpc, callServer, callBrowser } from 'path/to/YabosFiveMRPC/client/client.js';Browser (NUI) — two options depending on your setup:
Option A — Plain
<script>tag (no bundler)<script src="../../YabosFiveMRPC/browser/browser.js"></script> <script> // Exposed as window.FiveMRPC const { registerRpc, callClient } = FiveMRPC; registerRpc('showMenu', () => { document.getElementById('menu').style.display = 'block'; }); </script>
Option B — ESM import (Vite, webpack, esbuild, or any bundler)
import { registerRpc, callClient } from '../../YabosFiveMRPC/browser/browser.esm.js'; registerRpc('showMenu', () => { document.getElementById('menu').style.display = 'block'; });
FiveM-RPC does not ship an index.html — it is a communication library, not a UI. You provide your own HTML page.
In your resource's fxmanifest.lua, declare your own UI page and include browser.js (or browser.esm.js) in your files:
-- your-resource/fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
dependency 'YabosFiveMRPC'
client_scripts { 'client.js' }
server_scripts { 'server.js' }
ui_page 'html/index.html'
files {
'html/index.html',
'html/app.js',
-- copy browser.js from YabosFiveMRPC into your own html/ folder,
-- or reference it via a relative path if FiveM allows it in your setup
'html/browser.js',
}The simplest approach is to copy YabosFiveMRPC/browser/browser.js into your own resource's HTML folder as part of your build step.
In a standard FiveM resource, getting data from the server to the browser (NUI) requires manually wiring up four separate events:
Browser → (PostMessage) → Client → (emitNet) → Server
Server → (emitNet) → Client → (SendNUIMessage) → Browser
Every request needs its own event name pair, its own response routing, and its own logic to match a response to the correct caller. When multiple requests happen simultaneously, it is extremely easy to route a response to the wrong caller.
FiveM-RPC wraps all of that into a single function call that returns a Promise:
// Instead of all that event wiring — just this:
const plates = await callServer('getAllLicensePlates');Any context (server, client, or browser) can call a procedure registered on any other context and await its return value directly.
The library ships three separate self-contained bundles — one per FiveM context:
| File | Context | Format |
|---|---|---|
server/server.js |
Server-side scripts | Node CJS bundle |
client/client.js |
Client-side scripts | Node CJS bundle |
browser/browser.js |
NUI pages (script tag) | IIFE — exposes window.FiveMRPC |
browser/browser.esm.js |
NUI pages (bundler) | ESM — named exports |
Each bundle is fully self-contained. There are no shared files and no inter-bundle imports at runtime. All internal modules (core, shared) are inlined into each bundle by esbuild at build time.
Client calls callServer('myProcedure', payload)
│
├─ Creates a pending Promise internally, keyed by a unique ID
└─ Sends emitNet('fivem-rpc:server:request', envelope)
Server receives the event
├─ Validates the envelope (age, resource name, payload size)
├─ Runs the registered handler
└─ Sends emitNet('fivem-rpc:server:response', playerId, result)
Client receives the response event
├─ Matches the response ID to the pending Promise
└─ Resolves or rejects it
Server calls callClient(playerId, 'myProcedure', payload)
│
├─ Creates a pending Promise tagged with expected playerId
└─ Sends emitNet('fivem-rpc:client:request', playerId, envelope)
Client receives the event
├─ Validates the envelope
├─ Runs the registered handler
└─ Sends emitNet('fivem-rpc:client:response', result)
Server receives the response
├─ Verifies the responder's playerId matches the expected one (anti-spoof)
├─ Matches ID to the pending Promise
└─ Resolves or rejects it
Client calls callBrowser('myProcedure', payload)
│
├─ Creates a pending Promise
└─ Sends SendNUIMessage({ type: 'fivem-rpc:nui:request-message', ... })
Browser receives via window.addEventListener('message', ...)
├─ Validates the message type and resource name
├─ Runs the registered handler
└─ POSTs result to https://<resource>/fivem-rpc:nui:response
Client receives via RegisterNuiCallbackType
└─ Resolves or rejects the pending Promise
Browser calls callClient('myProcedure', payload)
│
└─ POSTs to https://<resource>/fivem-rpc:nui:request
Client receives via RegisterNuiCallbackType
├─ Runs the registered handler
└─ Returns result directly in the POST response body
Browser receives the fetch response
└─ Resolves or rejects the Promise
Note: Browser → Server is not a direct path. The browser always goes through the client (browser → client → server). This is intentional and matches FiveM's NUI security model — the browser cannot emit net events directly.
Server
import { registerRpc } from '...server.js';
registerRpc('getPlayerMoney', (payload, ctx) => {
return getPlayerMoney(ctx.playerId!);
});Client
import { callServer } from '...client.js';
const money = await callServer('getPlayerMoney');
console.log(`You have $${money}`);Client
import { registerRpc } from '...client.js';
registerRpc('isPlayerDriving', () => {
return IsPedInAnyVehicle(PlayerPedId(), false);
});Server
import { callClient } from '...server.js';
const driving = await callClient(source, 'isPlayerDriving');Server
registerRpc('getServerTime', () => Date.now());Client
registerRpc('getServerTime', () => callServer('getServerTime'));Browser
const { callClient } = FiveMRPC; // or import from browser.esm.js
const time = await callClient('getServerTime');
document.getElementById('time').textContent = new Date(time).toLocaleString();Use trigger* variants when you don't need a response back. They send the call and return immediately — no Promise, no await.
// Client
triggerServer('logEvent', { type: 'entered', zone: 'hospital' });
// Server
triggerClient(playerId, 'showNotification', { message: 'Welcome!' });
// Client
triggerBrowser('updateHUD', { health: 87, armor: 100 });Register a procedure on the server that any client or the server itself can call.
registerRpc('getWeather', () => GetWeatherTypeHashName(GetPrevWeatherTypeHashName()));Remove a registered procedure. Returns true if it existed.
Call a procedure registered on the same server (local — no network round-trip).
Call a procedure registered on a specific client and await the result.
const pos = await callClient(source, 'getPosition');Fire-and-forget version of callClient.
Register a procedure on the client that the server or browser can call.
Remove a registered procedure.
Call a procedure on the same client (local).
Call a procedure registered on the server.
const inventory = await callServer('getInventory');Call a procedure registered in the NUI browser.
const value = await callBrowser('getInputValue');Fire-and-forget version of callServer.
Fire-and-forget version of callBrowser.
Load via <script src="browser.js"> (exposes window.FiveMRPC) or import from browser.esm.js in a bundled app.
Register a procedure in the browser that the client can call.
FiveMRPC.registerRpc('toggleInventory', (payload) => {
document.getElementById('inventory').classList.toggle('visible');
});Remove a registered procedure.
Call a procedure in the same browser context (local).
Call a procedure registered on the client and await the result.
const name = await FiveMRPC.callClient('getLocalPlayerName');Fire-and-forget version of callClient.
By default all payloads and return values are typed as unknown. You can get full end-to-end types by augmenting the RpcProcedures interface once in a shared declaration file:
// types/rpc.d.ts
declare module 'fivem-rpc/server' { // adjust path to match your import
interface RpcProcedures {
getPlayerMoney: {
request: { playerId: number };
response: number;
};
getInventory: {
request: void;
response: { item: string; count: number }[];
};
}
}After that, all calls are fully typed — no casts needed:
const money = await callServer('getPlayerMoney', { playerId: 1 });
// ^? number — TypeScript knows thisawait callServer('heavyQuery', payload, {
timeoutMs: 15_000 // default is 7000ms
});| Option | Type | Default | Description |
|---|---|---|---|
timeoutMs |
number |
7000 |
Milliseconds before the call rejects with TIMEOUT |
registerRpc('submitForm', handler, {
rateLimit: { windowMs: 5_000, maxRequests: 3 },
validate: (payload) => {
if (!payload?.name) return 'name is required';
return null;
}
});| Option | Type | Default | Description |
|---|---|---|---|
rateLimit |
{ windowMs, maxRequests } |
30 req/s | Per-caller rate limit. Set false to disable. |
validate |
(payload, ctx) => string | null |
— | Return an error string to reject with BAD_REQUEST |
Every handler receives a context object as its second argument:
registerRpc('example', (payload, ctx) => {
ctx.id // unique ID for this request
ctx.name // procedure name
ctx.peer // 'server' | 'client' | 'browser' | 'local'
ctx.playerId // net ID of the calling player (server-side only)
ctx.resource // resource name the call came from
});Use ctx.playerId on the server to identify the caller without trusting any player-supplied value.
All procedures are rate-limited by default: 30 requests per second per caller.
Each bucket is scoped independently to peer + playerId + procedureName — players cannot starve each other's quotas, and one spammy procedure doesn't affect others.
// Stricter
registerRpc('expensiveQuery', handler, {
rateLimit: { windowMs: 10_000, maxRequests: 5 }
});
// Disabled (trusted internal calls)
registerRpc('internalSync', handler, {
rateLimit: false
});Local call() invocations always bypass rate limiting.
registerRpc('sendMessage', handler, {
validate: (payload) => {
if (typeof payload?.text !== 'string') return 'text must be a string';
if (payload.text.length > 200) return 'text is too long';
return null; // ok
}
});Returning any non-null string rejects the call with BAD_REQUEST before your handler ever runs. The caller receives that message as the rejection reason.
All protections below are automatic — no configuration required.
| Protection | What it does |
|---|---|
| Resource name validation | Every envelope must carry the correct resource name. Cross-resource spoofed calls are dropped. |
| Replay attack prevention | Envelopes older than 10 seconds are rejected even if otherwise valid. A captured packet cannot be replayed. |
| Payload size cap | Payloads over 256 KB are rejected before any handler runs. |
| Player ID verification | Server-side responses are verified to come from the expected player. Another player cannot inject a fake response. |
| Rate limiting | Every handler is protected against call flooding by default. |
| Error detail stripping | Internal error details are never forwarded over the network. Callers only receive a safe message and error code. |
| Pending call cap | Maximum 512 simultaneous pending calls per context prevents memory exhaustion. |
Set these in server.cfg if needed. Both default to false and are safe to omit.
# Print a debug log for every RPC call (development only)
set fivem_rpc_debug "true"
# Include internal handler error details in responses (never use in production)
set fivem_rpc_expose_errors "true"
All call* functions return a Promise that rejects with an RpcError on failure:
import { callServer, RpcError } from '...client.js';
try {
const result = await callServer('getInventory');
} catch (err) {
if (err instanceof RpcError) {
console.log(err.message); // human-readable
console.log(err.code); // machine-readable (see table below)
}
}| Code | Cause |
|---|---|
PROCEDURE_NOT_FOUND |
No handler registered under that name in the target context |
TIMEOUT |
Handler did not respond within timeoutMs |
BAD_REQUEST |
Payload failed validation or envelope was malformed |
RATE_LIMITED |
Caller exceeded the procedure's rate limit |
HANDLER_ERROR |
Handler threw an unhandled exception |
TRANSPORT_ERROR |
Network or protocol-level failure |
MALFORMED_RESPONSE |
Response envelope could not be parsed |
FORBIDDEN |
Available for your own handlers: throw new RpcError('...', 'FORBIDDEN') |
| Limit | Value |
|---|---|
| Maximum payload size | 256 KB |
| Maximum procedure name length | 128 characters |
| Maximum simultaneous pending calls | 512 per context |
| Default request timeout | 7 seconds |
| Maximum request age (replay protection window) | 10 seconds |
| Default rate limit | 30 requests / second / caller |
Unlicense — public domain. Do whatever you want with it.