Skip to content

yabooo666/FiveM-RPC

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

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 YabosFiveMRPC

Step 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) →  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.


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 it

Server → 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 it

Client → 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 Promise

Browser → 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 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.


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 this

Options

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.

About

FiveM-RPC for devs who are working on their own independent core without Qbox and etc...

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors