Bouncing Steam session requests in Rust, before JavaScript can jack in
ISteamNetworkingMessages expects a session to be accepted or rejected before its callback returns, and JavaScript can't jack in fast enough. So my steamworks.js fork makes the call from a Rust-side guest list that JavaScript loads up ahead of the knock, choom.
One of the pieces of chrome I bolted onto my steamworks.js fork is networking_messages, which runs about 370 lines of Rust, and nearly every design call in it traces back to a single constraint, the kind of street law you don’t get to negotiate with. When a peer opens a session with you, Steam plays bouncer and asks whether you want to let them in, and it expects the answer before its callback returns. At that moment your JavaScript is benched with no chance to run, so whatever makes the call has to already be sitting on the Rust side, chromed up and waiting, choom.
I’m writing this gig up because the problem isn’t specific to networking. If you’re wrapping any Steam API that expects you to make a decision inside a callback, you’ll slam into the same ICE, and the approach below should carry over to whatever run you’re on, netrunner.
How ISteamNetworkingMessages handles mercs knocking at the door
ISteamNetworkingMessages is Valve’s connectionless messaging interface. Instead of opening a socket and babysitting a connection yourself like a gonk on night watch, you fire a message at a Steam ID, and Steam opens a session on demand the first time you do. The upstream steamworks.js only exposed the legacy ISteamNetworking, which Valve has deprecated and left to flatline, so wrapping the newer interface was one of the first gigs the fork ran for Cozy Coast.
On the receiving side of the deck, the first message from a merc you haven’t dealt with yet sets off a session request, and your code has to decide whether that merc gets past the door. In the steamworks Rust crate (version 0.13.1, the chrome the fork builds on), you handle this by registering a closure, your bouncer on the payroll:
pub fn session_request_callback(
&self,
mut callback: impl FnMut(SessionRequest) + Send + 'static,
)
The closure receives the SessionRequest by value, which means it owns the request outright and is the only fixer in the whole operation who can answer it. The request has accept() and reject() methods, and if the closure returns without calling either one, the Drop impl acts as the ICE and rejects the session for you:
impl Drop for SessionRequest {
fn drop(&mut self) {
if !self.accepted {
self.reject_inner();
}
}
}
reject_inner is a straight call to CloseSessionWithUser. In practice, this means you have to make the call before the closure returns, choom, because the moment it returns the request gets dropped and, unless you accepted it, the merc’s session is flatlined on the spot.
Why the JavaScript handler jacks in too late
To see why JavaScript can’t make that call, it helps to trace how callbacks reach the closure in the first place, down in the chrome where the timing gets decided. Steam doesn’t push callbacks on its own, so some netrunner has to jack in and call SteamAPI_RunCallbacks on a steady beat. In the fork, init in index.js starts a timer that does exactly that:
runCallbacksInterval = setInterval(runCallbacks, 1000 / 30)
The timer fires 30 times a second, roughly every 33 ms, and because it’s a setInterval, it runs on the JavaScript thread, the same wetware that runs everything else in your game. The native function it calls is only two lines of lean chrome:
#[napi]
pub fn run_callbacks() {
client::get_client().run_callbacks();
}
Now picture a single tick, choom. The JS thread calls runCallbacks() and jacks into native code. Inside that call, Steam dispatches the pending session request, and the Rust closure runs, still on the JS thread and still deep inside the same native call. The only way for the closure to tip off JavaScript about the request is through a napi ThreadsafeFunction, and calling one of those doesn’t run the JavaScript function straight away. What it does is queue a call onto the event loop, like a message left with a fixer that only gets read whenever the loop swings back by the Afterlife.
The problem is that the event loop is the very merc currently executing runCallbacks(), so it can’t pick up anything from its queue until that gig wraps. The queued handler only runs after the timer callback returns, which happens after the Steam callback has returned, which in turn happens after the SessionRequest has been dropped. By then the peer has already been bounced, so any “accept” that JavaScript cooks up would be for a request that’s already been zeroed out of existence.
Keeping the guest list chromed into Rust, where the ICE lives
Since JavaScript can’t answer the question at the moment Steam asks it, choom, the answer has to be ready before any merc knocks. What I did was keep the policy in Rust, stashed in two statics that the closure can read synchronously while it’s still holding the request, like a bouncer at the Afterlife with the guest list already in hand and no need to call the fixer:
static ALLOW_ALL: AtomicBool = AtomicBool::new(false);
lazy_static::lazy_static! {
static ref ALLOWED: Mutex<HashSet<u64>> = Mutex::new(HashSet::new());
}
ALLOWED is a set of 64-bit Steam IDs behind a mutex, the guest list proper, and ALLOW_ALL is a single flag that drops the ICE and waves every merc through regardless of what’s on the list. JavaScript plays fixer and works the list through three functions: allowPeer writes a name on it, disallowPeer scratches one off, and clearAllowedPeers zeroes the whole page. The closure that initSessionCallbacks registers then checks that same set whenever a request comes knocking:
let permitted =
ALLOW_ALL.load(Ordering::Relaxed) || ALLOWED.lock().unwrap().contains(&remote);
if permitted {
request.accept();
} else {
request.reject();
}
requested.call(
(BigInt::from(remote), permitted),
ThreadsafeFunctionCallMode::NonBlocking,
);
The decision gets made in Rust, against intel that JavaScript wrote earlier, and JavaScript gets debriefed afterwards with the verdict passed along as the second argument. That means the onSessionRequest handler only works as a notification, the way a fixer hears about a gig that already went down across town. You can use it to log the request or light up the UI, but by the time it runs the session has already been accepted or rejected, so nothing the handler does can change the outcome, no matter how many eddies it throws at the problem.
Filling the guest list from the lobby’s street chatter
The next question is who gets to put mercs on the list, and for Cozy Coast the fixer is the lobby. Everyone who should be able to message you is, by definition, in your Steam lobby, and Steam already broadcasts every merc walking in and every choom heading out through LobbyChatUpdate. So the simplest play is to allow a peer when they enter the lobby and disallow them when they delta, which the README wires up like this:
const nm = client.networking_messages
nm.initSessionCallbacks(
(steamId64, accepted) => { /* peer requested a session; accepted per policy */ },
(steamId64) => {
// Session broke. Acknowledge it before sending to that peer again.
nm.closeSessionWithUser(steamId64)
},
)
client.callback.register(steamworks.SteamCallback.LobbyChatUpdate, ({ user_changed, member_state_change }) => {
if (member_state_change === 'Entered') nm.allowPeer(user_changed)
else nm.disallowPeer(user_changed)
})
That === 'Entered' comparison was an easy place to catch a bug, choom. Upstream’s callbacks.d.ts typed member_state_change as a numeric enum, but callback payloads ride through serde, which serializes the variant as its name. So if you wrote your code against the types, you were comparing a string to a number, and the check never matched, a gonk trap dressed up in corpo-grade typings. To fix that, the fork now types the field as 'Entered' | 'Left' | 'Disconnected' | 'Kicked' | 'Banned', and the handler above treats anything other than 'Entered' as the merc walking out.
There’s also setAllowAllSessions(true), which sets ALLOW_ALL so that every session request gets waved through without anyone glancing at the set. The doc comment on it says that a shipped host should allow only the peers it knows joined its lobby, because accepting everyone skips the lobby check entirely and leaves the door open for any scav or corpo spy with a Steam ID. I run it for private playtests with my own crew of chooms and nowhere else, never on a live gig.
The race on a merc’s first message, and how the sender jacks back in
The guest list approach has one gap, and I documented it in the source instead of burying it under corpo PR, so nobody has to find it the hard way on a live run. The decision happens when a peer’s first message lands, so if that message gets there before your allowPeer call has run, the session is rejected once. This can happen when a merc joins the lobby and immediately fires off a hello, before your LobbyChatUpdate handler has had the chance to put their name on the list.
Recovering from it is the sender’s job, a merc cleaning up its own run: it sees the flatlined session through onSessionFailed, calls closeSessionWithUser for that peer to acknowledge the body, and its next send opens a fresh session request, jacking back in clean. By then the receiver has had time to allow the peer, so the second knock gets accepted and the merc is in. That’s why the failed handler in the README does nothing except close the session and clear the scene. The two-machine test in test/networking_messages.js goes a step further and also closes the session when a member deltas out of the lobby.
Send errors that tell you what flatlined the run
sendMessageToUser used to return a bare bool, which told you something had flatlined but nothing about what killed it, so your code had no way to tell a broken session apart from a message that was simply too heavy for the rig to haul, like a gonk guessing at a crime scene. It now throws instead, and the error message is the EResult name, stamped on like a toe tag:
crate::client::get_client()
.networking_messages()
.send_message_to_user(identity, flags, &data, channel)
.map_err(|e| Error::from_reason(format!("{e:?}")))
{e:?} is the Debug form of the crate’s SteamError enum, so a broken session shows up in JavaScript as an error whose message is 'NoConnection', and you can branch on that string the way a netrunner reads a threat readout. The doc comment lists the ones worth catching: NoConnection means you should close the session and retry, LimitExceeded means the message is too big or too much is already queued, and InvalidParam means you fed the chrome garbage, a gonk move with no one to blame but your own deck.
try {
nm.sendMessageToUser(peerId64, nm.MessageSendType.Reliable, Buffer.from(text), 0)
} catch (e) {
if (e.message === 'NoConnection') nm.closeSessionWithUser(peerId64)
}
When a toe tag isn’t enough intel for your netrunner, getSessionConnectionInfo(steamId64) gives you the full ripperdoc scan: the session state, ping, local and remote delivery quality, bytes and packets per second, pending reliable and unreliable bytes, and whether the route is relayed. For this function I went below the crate’s wrapper, peeling its chrome back, and call SteamAPI_ISteamNetworkingMessages_GetSessionConnectionInfo directly through the raw sys bindings, then read the two structs it fills in field by field. Relay detection originally tested m_idPOPRelay != 0, but that only spots Steam Datagram Relay, so it now reads the Relayed connection flag instead, which catches TURN as well, so no corpo relay slips past the scan. One more detail is worth knowing about endReason: it reads 0 both when the session is healthy and when Steam logged no cause of death, and the doc comment notes that the second case happens on loopback pipe closes, so a 0 on its own doesn’t prove the session is still breathing, choom.
Receiving messages by hitting the dead drop, no fixer required
Incoming messages never touch the callback path at all, so there’s no ICE to race. Instead, you hit the dead drop and pull them yourself on whatever schedule suits your game, with no fixer standing in the middle skimming eddies:
setInterval(() => {
for (const { steamId, data, channel } of nm.receiveMessagesOnChannel(1, 32)) {
// ...
}
}, 50)
receiveMessagesOnChannel(channel, batchSize) copies each message’s bytes into a Node Buffer and hands the whole batch back in one array. The payoff is that nothing gets queued across the napi boundary for each individual message, so you don’t pay a toll in eddies on every package, and your code checks the drop when it’s ready to deal with what’s inside. The README polls every 50 ms and the two-machine test every 66 ms, which are both slower than the 30 Hz callback pump. That’s preem, choom, because only session requests ride the pump, the one netrunner on the clock, while the message payloads get scooped up by these separate polls.
For testing, the single-machine smoke test (node test/smoke.js) tries to send a loopback message to your own Steam ID. If nothing comes back within 5 seconds, it marks that step as skipped instead of failed, with a note that Steam may not loop back to self, since a missing loopback doesn’t necessarily mean the chrome is busted. The real proof needs two rigs in the same lobby, each running node test/networking_messages.js, and you can jack into the tests along with the rest of the code on GitHub.