yabosfivemrpc
v0.1.1
Published
A secure, Promise-based RPC library for FiveM TypeScript/JavaScript resources.
Downloads
12
Readme
FiveM-RPC
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.
Table of Contents
- Installation
- How It Works
- Quick Start
- API Reference
- TypeScript: Typed Procedures
- Options
- Handler Context
- Rate Limiting
- Payload Validation
- Security
- Configuration Convars
- Error Handling
- Limits
Installation
Step 1 — Download the resource
Go to the Releases page and download the latest YabosFiveMRPC.zip.
Extract it — you will get a folder called YabosFiveMRPC.
Step 2 — Add to your server
Place the YabosFiveMRPC folder inside your server's resources/ directory and add this line to your server.cfg:
ensure YabosFiveMRPCStep 3 — Declare the dependency
In your own resource's fxmanifest.lua, add:
dependency 'YabosFiveMRPC'Step 4 — Use it in your scripts
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'; });
Step 5 — Your NUI manifest
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.
How It Works
The Problem It Solves
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) → BrowserEvery 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.
Architecture
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.
Message Flow
Client → Server
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 itServer → Client
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 itClient → Browser (NUI)
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 PromiseBrowser → Client (NUI)
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 PromiseNote: 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.
Quick Start
Server registers a procedure, client calls it
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}`);Server calls a client procedure
Client
import { registerRpc } from '...client.js';
registerRpc('isPlayerDriving', () => {
return IsPedInAnyVehicle(PlayerPedId(), false);
});Server
import { callClient } from '...server.js';
const driving = await callClient(source, 'isPlayerDriving');Browser calls client, client calls server
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();Fire-and-forget (no return value needed)
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 });API Reference
Server
registerRpc(name, handler, options?)
Register a procedure on the server that any client or the server itself can call.
registerRpc('getWeather', () => GetWeatherTypeHashName(GetPrevWeatherTypeHashName()));unregisterRpc(name)
Remove a registered procedure. Returns true if it existed.
call(name, payload?, options?)
Call a procedure registered on the same server (local — no network round-trip).
callClient(playerId, name, payload?, options?)
Call a procedure registered on a specific client and await the result.
const pos = await callClient(source, 'getPosition');triggerClient(playerId, name, payload?)
Fire-and-forget version of callClient.
Client
registerRpc(name, handler, options?)
Register a procedure on the client that the server or browser can call.
unregisterRpc(name)
Remove a registered procedure.
call(name, payload?, options?)
Call a procedure on the same client (local).
callServer(name, payload?, options?)
Call a procedure registered on the server.
const inventory = await callServer('getInventory');callBrowser(name, payload?, options?)
Call a procedure registered in the NUI browser.
const value = await callBrowser('getInputValue');triggerServer(name, payload?)
Fire-and-forget version of callServer.
triggerBrowser(name, payload?)
Fire-and-forget version of callBrowser.
Browser (NUI)
Load via <script src="browser.js"> (exposes window.FiveMRPC) or import from browser.esm.js in a bundled app.
registerRpc(name, handler, options?)
Register a procedure in the browser that the client can call.
FiveMRPC.registerRpc('toggleInventory', (payload) => {
document.getElementById('inventory').classList.toggle('visible');
});unregisterRpc(name)
Remove a registered procedure.
call(name, payload?, options?)
Call a procedure in the same browser context (local).
callClient(name, payload?, options?)
Call a procedure registered on the client and await the result.
const name = await FiveMRPC.callClient('getLocalPlayerName');triggerClient(name, payload?)
Fire-and-forget version of callClient.
TypeScript: Typed Procedures
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 thisOptions
Call options
await callServer('heavyQuery', payload, {
timeoutMs: 15_000 // default is 7000ms
});| Option | Type | Default | Description |
|---|---|---|---|
| timeoutMs | number | 7000 | Milliseconds before the call rejects with TIMEOUT |
Register options
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 |
Handler Context
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.
Rate Limiting
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.
Payload Validation
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.
Security
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. |
Configuration Convars
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"Error Handling
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)
}
}Error codes
| 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') |
Limits
| 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 |
License
Unlicense — public domain. Do whatever you want with it.
