Module Accessors
RoExpress has four patterns for accessing its modules. Each one exists for a reason | knowing which to use and why removes the confusion about RoExpress("App") vs RoExpress.GetApp() vs RoExpress.Bridge.
The four patterns
1 | Typed accessors RoExpress.GetApp() / RoExpress.GetNetwork()
Use these for App (server) and Network (client). They return a fully-typed instance | Luau infers the complete method list without any extra annotation. Autocomplete and type checking work out of the box.
-- Server script
local RoExpress = require(game.ReplicatedStorage.RoExpress)
local app = RoExpress.GetApp() -- type is inferred as App.Type
app:Get("player/:id=number", function(req, res) -- autocomplete works
res:Send({ userId = req.params.id })
end)
-- Client script
local RoExpress = require(game.ReplicatedStorage.RoExpress)
local network = RoExpress.GetNetwork() -- type is inferred as Network.Type
network:GetAsync("player/123")
:Then(function(res) print(res.data.userId) end)
GetApp() and GetNetwork() | never use the call form for these two modules. The only reason to use RoExpress("App") would be if you need any intentionally, which is rare.
2 | Call form RoExpress("ModuleName")
Returns the module's singleton instance. The return type is any, so Luau won't give you autocomplete. Use this for Broadcast, Listener, Stream, and other instantiated modules where the typed accessor doesn't exist.
-- Server
local broadcast = RoExpress("Broadcast")
local tamper = RoExpress("Tamper")
-- Client
local listener = RoExpress("Listener")
Every call is cached | calling RoExpress("Broadcast") from two different scripts returns the same instance. The module is only constructed once.
3 | Property access RoExpress.ModuleName
Used for utility modules that are not instantiated | they are the module table directly, with full type information. These are set as direct properties on the root module rather than going through the call cache.
-- Both contexts
local Codec = RoExpress.Codec -- compression
local Base64 = RoExpress.Base64 -- encode / decode
local Bridge = RoExpress.Bridge -- internal event bus
local TypeCoercer = RoExpress.TypeCoercer -- type serialisation
local Promise = RoExpress.Promise -- async utilities
-- Server only
local Harpy = RoExpress.Harpy -- outbound HTTP factory
local Tamper = RoExpress.Tamper -- exploit detection singleton
Harpy is a factory, not a singleton. You always call Harpy.New(config) to create a client | the property just gives you the factory table:
local Harpy = RoExpress.Harpy
local webhook = Harpy.New({ base = WEBHOOK_URL, retries = 3 })
4 | Named port RoExpress("Network", "portName")
Client-only. Passing a second argument returns a Network or Listener wired to that port's dedicated RemoteEvent, not the main channel. The port must already exist on the server (created via app:Listen()).
-- Client: connect to the "combat" port
local combatNet = RoExpress("Network", "combat")
local combatListener = RoExpress("Listener", "combat")
combatNet:Post("shoot/:targetId=number", nil, function(res)
showHit(res.data)
end)
combatListener:On("hit.confirmed", function(data)
print(data.damage)
end)
The port name is also cached | RoExpress("Network", "combat") called from multiple scripts returns the same instance.
On the server, passing a port name to the call form is a no-op | the name is ignored and the main App instance is returned. Ports are created server-side via app:Listen(), not via the accessor.
-- Server: create a port (NOT via accessor)
local app = RoExpress.GetApp()
app:Listen("combat", function(port)
port:Post("shoot/:targetId=number", function(req, res)
res:Send(handleShoot(req.player, req.params.targetId))
end)
end)
Full module reference
Every module, which context it runs in, and the correct access form:
| Module | Context | Access form | Notes |
|---|---|---|---|
| Server | |||
| App | Server | RoExpress.GetApp() | Typed. Use this | not the call form. |
| Broadcast | Server | RoExpress("Broadcast") | Returns any. |
| Tamper | Server | RoExpress.Tamper | Singleton. Property access, fully typed. |
| Harpy | Server | RoExpress.Harpy | Factory | call .New(config) per API. |
| Port | Server | app:Listen("name", fn) | Created through App, not the root accessor. |
| Client | |||
| Network | Client | RoExpress.GetNetwork() | Typed. Use this | not the call form. |
| Listener | Client | RoExpress("Listener") | Main channel. Returns any. |
| Benchmark | Client | RoExpress("Benchmark") | Returns any. |
| Named ports (client only) | |||
| Network on port | Client | RoExpress("Network", "name") | Network wired to that port's RemoteEvent. |
| Listener on port | Client | RoExpress("Listener", "name") | Listener wired to that port's RemoteEvent. |
| Both contexts | |||
| Stream | Both | RoExpress("Stream") | Schema-defined binary channels. |
| Bridge | Both | RoExpress.Bridge | Server-side only in practice; client import is safe. |
| Codec | Both | RoExpress.Codec | Compression utilities. |
| Base64 | Both | RoExpress.Base64 | Encode / decode. |
| TypeCoercer | Both | RoExpress.TypeCoercer | Type serialisation. |
| Promise | Both | RoExpress.Promise | Async utilities. |
Why typed vs untyped matters
In Luau, a variable typed as any provides no autocomplete and suppresses type errors. For a framework with dozens of methods, that matters. The example below intentionally misspells Post as Pots to show what each form does with the mistake:
-- Untyped | 'any' silences all method errors
local app = RoExpress("App")
app:Pots("player", handler) -- 'Post' misspelled as 'Pots' | no Studio error, fails at runtime
-- Typed | Studio flags the misspelling before you ever run
local app = RoExpress.GetApp()
app:Pots("player", handler) -- Type error: 'Pots' is not a member of App | did you mean 'Post'?
Instance caching
Every module is created once and cached on the root RoExpress object. Calling the same accessor multiple times from different scripts is safe | you always get the same instance back.
-- ServerScriptService/A.server.luau
local app1 = RoExpress.GetApp()
-- ServerScriptService/B.server.luau
local app2 = RoExpress.GetApp()
-- app1 and app2 are the exact same object
print(app1 == app2) -- true
Port instances are cached by "ModuleName:portName" key | RoExpress("Network", "combat") returns the same Network instance every time.
Wrong context errors
Calling a server-only module on the client (or vice versa) throws an assertion immediately with a message that names the module and which context you're in. You will never get a silent nil.
-- In a LocalScript:
local app = RoExpress.GetApp()
-- Error: RoExpress: 'App' is not available in this context (Client)
See also
App | server routing API · Network | client request API · Ports | named isolated pipelines · Types | exported Luau types · Request Pipeline | what happens after the accessor