Skip to Content
ScriptingJavaScript Async & C# Exports

JavaScript asynchronous I/O and C# exports

Server JavaScript runs in Node; client JavaScript does not. The server can use asynchronous file I/O, but a Node I/O callback must hand execution back to the game scheduler before calling FiveM natives or another resource’s exports. Capturing source avoids one mistake; checking that the original session still exists avoids another.

This guide uses an original, compiled Legacy/Mono runtime lab, not a syntax-only Lua translation. Download the source resource. Its companion C# guide explains the export and NUI callback implementation. The download contains source and reproducible build commands; the CI workflow compiles the two CitizenFX assemblies separately from the website.

Build and install the exact example

Extract the source into a working directory named runtime-lab. Install Node 22 and a .NET 10 SDK, then run from that directory:

node build.mjs node --test tests/server.test.cjs dotnet run --project tests/ProtocolTests.csproj -c Release

The example pins CitizenFX.Core.Client and CitizenFX.Core.Server to 1.0.26803, targeting net452. The verified build used Node 22.23.2 and .NET SDK 10.0.401. Compiling net452 with a new SDK does not turn it into an Enhanced/.NET resource. See track compatibility.

Copy the folder, including generated dist/, into resources/[local]/runtime-lab. The manifest explicitly selects node_version '22' and loads the C# server assembly before server.js. Start it from server.cfg or the server console:

ensure runtime-lab # Replace only in your private development config, never in a public example. add_ace identifier.license:REPLACE_ME docs.banner allow

Connect a permitted test client and run /fd_banner. F8 should show [Lab] Banner: [Lab] Development lab: the export returned a plain string. An unpermitted client should get an error, not the private file’s contents.

Follow one request across the boundaries

The client.js command creates a correlation ticket, permits one pending request and starts a seven-second timer. It sends only the ticket; the client cannot choose a filesystem path or the banner text. The server.js handler captures the session ID synchronously, checks the narrow docs.banner ACE, consumes a cooldown slot and reads a fixed file under its own resource.

The critical server pattern is:

// server.js: excerpt; the download supplies validation, timers and cleanup. const src = Number(global.source); // Keep a session object in a Map before beginning asynchronous work. fs.readFile(bannerPath, 'utf8', (error, text) => { setImmediate(() => { // Validate the current session and request ticket before natives/exports. // Recheck permission here because it may have changed while reading. // Parse the bounded JSON and call the synchronous C# export here. }); });

setImmediate is not merely a delay to make an arbitrary race disappear. The JavaScript runtime manual  documents the scheduler boundary for Node I/O callbacks. The client runtime cannot use Node’s fs, and the NUI browser is a third environment with window and document but no direct server authority.

Prevent a delayed reply reaching the wrong session

The full example’s completion guard checks both identities:

function current(src, session, ticket) { return sessions.get(src) === session && session.ticket === ticket; }

On playerDropped, the map entry is deleted and its timer is cleared. A new connection reusing the same numeric ID gets a different object, so an old file-read callback cannot reply to it. The request ticket also prevents a timed-out operation from completing a later request within the same session.

A five-second server timer completes the request with an error if necessary. Completion clears the timer and ticket; a late callback then fails the guard. The client timer is longer so it can receive a server-side timeout before declaring its own missing response. These are teaching timeout values, not universal production limits.

Pass data, not promises, through the export

After JSON and permission validation, server JS calls:

const formatted = global.exports[resource].FormatBanner(data.text);

The C# export returns a plain string synchronously. The lab does not pass a JavaScript Promise or C# Task across the language boundary and hope that it is awaited. Build request/reply or explicit callback contracts for asynchronous cross-resource operations, including timeout, error and restart behavior. A provider being listed first in the manifest is not a substitute for diagnosing a missing or failed assembly; this example catches a missing export and replies with a controlled error.

Failure tests and limits

The twelve Node tests execute the actual server.js in a controlled event/I/O harness. They cover scheduler handoff, invalid requests, denied/revoked ACE, repeated input, disconnect/ID reuse, timeout and late completion, malformed/oversized JSON, failed reads, missing exports and resource stop. They do not emulate FXServer, a real Node scheduler inside FiveM or C# marshaling.

Repeat those cases on a development server and record artifact/game build results. The file is small, fixed-path demonstration storage—not a transaction-safe database. Do not put private/ in manifest files, client_scripts or shared_scripts; distributed client files are not private. Continue with resource storage, secure events and two-client acceptance tests.