Using Flux from code that runs on both the server and the client.
Flux.Server and Flux.Client are context-guarded. Each one throws if called from the
wrong side:
Flux.Server("Name") -- on a client: "Flux.Server can only be called on the server."Flux.Client("Name") -- on the server: "Flux.Client can only be called on the client."
Flux has no unified constructor. There is no Flux.ReferenceBridge, Flux.Event or
auto-detecting factory in the public API.The Bridge module inside Flux is unrelated. It is the internal buffer batching and
flush layer, not an environment selector, and it is not re-exported from the root
module. See Bridge if you are curious what it actually does.
So a ModuleScript required from both sides cannot call either constructor
unconditionally. Branch on RunService instead.
Now both sides require the same module and get the object appropriate to their context:
local Net = require(ReplicatedStorage.Net)Net.Chat:Connect(function(player, message) print(player.Name, message) -- player parameter presentend)Net.Chat:FireAll("Welcome!")
local Net = require(ReplicatedStorage.Net)Net.Chat:Connect(function(message) print(message) -- no player parameterend)Net.Chat:Fire("Hello from the client!")
This also happens to be the right place to keep schemas, since both sides have to
declare identical layouts. One definition, so the two cannot drift. Compare with the
shared-module approach in Schemas.
The helper above returns a union of two different shapes, which the type checker cannot
narrow at the call site. Give each side its own typed view instead:
ReplicatedStorage/Net.luau
--!strictlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local RunService = game:GetService("RunService")local Flux = require(ReplicatedStorage.Flux)export type ChatArgs = (string)export type DamageArgs = (Instance, number)local IS_SERVER = RunService:IsServer()local function raw(name: string, schema: { Flux.Types.Type }?): any if IS_SERVER then return Flux.Server(name, schema) end return Flux.Client(name, schema)endlocal Net = { Chat = raw("Chat"), Damage = raw("Damage", { Flux.Types.Instance(), Flux.Types.UInt16() }),}-- Two views over the same objects, one per context.export type ServerNet = { Chat: Flux.Server<ChatArgs>, Damage: Flux.Server<DamageArgs>,}export type ClientNet = { Chat: Flux.Client<ChatArgs>, Damage: Flux.Client<DamageArgs>,}return Net
local Net = require(ReplicatedStorage.Net) :: Net.ServerNet--> Net.Chat:Fire(player, "hi") fully typed, player required
local Net = require(ReplicatedStorage.Net) :: Net.ClientNet--> Net.Chat:Fire("hi") fully typed, no player
Because the argument packs, ChatArgs and DamageArgs, are declared once and reused by
both views, the server and client signatures can never disagree about what an event
carries. They differ only in whether a Player is prepended, which Flux’s generic types
handle for you.
A shared module hides the context, so it is easy to forget these differences. They do not
disappear.
Server
Client
Connect callback
(player, ...)
(...)
Wait returns
(Player, ...)
(...)
Broadcast
FireAll, FireAllUnreliable
not available
Invocation
OnInvoke handler
Invoke caller
Code that genuinely runs identically on both sides cannot call Connect with one
callback signature. If a shared function needs to handle received messages, keep the
listener registration in context-specific scripts and let the shared module own only
construction.