Getting Started

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)
Rule of thumb: always use 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:

ModuleContextAccess formNotes
Server
App ServerRoExpress.GetApp() Typed. Use this | not the call form.
Broadcast ServerRoExpress("Broadcast") Returns any.
Tamper ServerRoExpress.Tamper Singleton. Property access, fully typed.
Harpy ServerRoExpress.Harpy Factory | call .New(config) per API.
Port Serverapp:Listen("name", fn) Created through App, not the root accessor.
Client
Network ClientRoExpress.GetNetwork() Typed. Use this | not the call form.
Listener ClientRoExpress("Listener") Main channel. Returns any.
Benchmark ClientRoExpress("Benchmark") Returns any.
Named ports (client only)
Network on port ClientRoExpress("Network", "name") Network wired to that port's RemoteEvent.
Listener on portClientRoExpress("Listener", "name") Listener wired to that port's RemoteEvent.
Both contexts
Stream BothRoExpress("Stream") Schema-defined binary channels.
Bridge BothRoExpress.Bridge Server-side only in practice; client import is safe.
Codec BothRoExpress.Codec Compression utilities.
Base64 BothRoExpress.Base64 Encode / decode.
TypeCoercerBothRoExpress.TypeCoercer Type serialisation.
Promise BothRoExpress.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