Forest Logo
search
package_2

roexpress

By @unofficialrobloxtutor

Roblox

Mirrored

RoExpress

A type-safe, rate-limited, Express.js-style networking framework for Roblox.

Author: DeathToTheStadium Version: 2.5.0 License: MIT Docs: https://roexpress.dev


Overview

RoExpress replaces scattered RemoteEvents with a single disciplined pipeline. One reliable RemoteEvent handles all request/response traffic. One UnreliableRemoteEvent handles all broadcasts. Every request is automatically versioned, rate-limited, routed, and optionally compressed.

Typical Roblox game:
    RemoteEvent1 → handler
    RemoteEvent2 → handler
    RemoteEvent3 → handler
    ... one remote per action, no structure, no rate limiting, no security

RoExpress:
    One reliable remote   → full request/response pipeline + server push
    One unreliable remote → full broadcast pipeline
    Named ports           → isolated pipelines for separate traffic domains
    Stream remotes        → dedicated 60hz binary FPS streaming

Module Tree

RoExpress
├── App            server  — routing, middleware, server push
│   ├── Router             — typed params, wildcards, globs, constraints
│   └── TokenBucket        — per-instance rate limiter
├── Network        client  — request/response, Promise API
├── Broadcast      server  — unreliable fire-and-forget
├── Listener       client  — broadcast + reliable push
├── Bridge         shared  — internal event bus
├── Tamper         server  — exploit detection
├── Codec          shared  — LZ77 + LZH (Deflate) compression (folder)
│   ├── LZ77               — sliding-window byte compression
│   └── LZH                — Deflate-compatible entropy coding
├── Port           server  — named isolated pipelines
├── Stream         shared  — schema-defined typed binary channels (folder)
│   ├── Types              — 20 built-in wire types + custom extension
│   ├── Schema             — compile-time field offsets, pack/unpack, delta
│   └── Channel            — channel instance, rate limiting, sequence numbers
├── TypeCoercer    shared  — type serialisation utility
├── Promise        client  — chainable async Network API
├── TokenBucket    shared  — rate limiter (used internally)
└── Base64         shared  — encode/decode utility

Installation

Wally (recommended)

[dependencies]
RoExpress = "unofficialrobloxtutor/roexpress@2.5.0"
wally install
local RoExpress = require(game.ReplicatedStorage.Packages.RoExpress)

Creator Store

Get it from the Roblox Creator Store and drop the ModuleScript into ReplicatedStorage.

Manual / GitHub

Clone or download from GitHub and place in ReplicatedStorage:

ReplicatedStorage
└── RoExpress          ← root ModuleScript (init.luau)
    ├── App
    ├── Network
    ├── Broadcast
    ├── Listener
    ├── Router
    ├── Codec
    ├── Bridge
    ├── Tamper
    ├── Port
    ├── Stream
    ├── TypeCoercer
    ├── Promise
    ├── TokenBucket
    ├── Version
    └── Base64

RoExpress creates its own RemoteEvents automatically — you don't touch them.


Quick Start

Server

local RoExpress = require(game.ReplicatedStorage.Modules.Libraries.RoExpress)
local app       = RoExpress("App")
local broadcast = RoExpress("Broadcast")
local bridge    = RoExpress("Bridge")

-- middleware — runs before every request
app:Use("logger", function(Player, Payload)
    print(Player.Name, Payload.method, Payload.route)
end)

-- typed param — req.params.userId is already a Lua number
app:Get("player/:userId=number", function(req, res)
    res:Send({ userId = req.params.userId })
end)

-- update a record — returns status only, no body
app:Put("player/:userId=number/name", function(req, res)
    -- update logic here
    res:Status(200):Send(true)
end)

-- delete a record — returns status only, no body
app:Delete("player/:userId=number", function(req, res)
    -- delete logic here
    res:Status(204):Send()
end)

-- compressed response — best on large tables (>2kb)
app:Get("feed/all", handler, { compress = true })

-- server push — reliable, no client request needed
app:PushAll("roundEnd", { winner = "PlayerName" })

-- internal bus — fire to other server modules
bridge.Fire("playerJoined", { player = Player })

Client

local RoExpress = require(game.ReplicatedStorage.Modules.Libraries.RoExpress)
local network   = RoExpress("Network")
local listener  = RoExpress("Listener")

-- GET with callback
network:Get("player/123", nil, function(res)
    if res.type == "error" then return end
    print(res.data.userId)
end)

-- PUT — update, expects status only back
network:Put("player/123/name", { name = "NewName" }, function(res)
    print(res.status) -- 200
end)

-- DELETE — remove, expects status only back
network:Delete("player/123", nil, function(res)
    print(res.status) -- 204
end)

-- GET with Promise
network:GetAsync("player/123")
    :Then(function(res) print(res.data.userId) end)
    :Catch(function(err) warn(err.message) end)

-- listen to both reliable push and unreliable broadcast
listener:On("roundEnd", function(data)
    print("Winner:", data.winner)
end)

Context Access

CallContextReturns
RoExpress("App")Server onlyApp instance
RoExpress("Network")Client onlyNetwork instance
RoExpress("Broadcast")Server onlyBroadcast instance
RoExpress("Listener")Client onlyListener instance
RoExpress("Bridge")BothShared singleton event bus
RoExpress("Tamper")Server onlyExploit detection singleton
RoExpress("Stream")BothSchema-defined typed binary channel singleton
RoExpress("TypeCoercer")BothType serialisation utility
RoExpress("Promise")Client onlyPromise factory
RoExpress("Base64")BothBase64 utility

Calling a server-only module on the client (or vice versa) throws an assertion with a clear context message.


API Reference

App (Server)

local app = RoExpress("App")

Routing

app:Get(route, handler, options?)
app:Post(route, handler, options?)
app:Put(route, handler, options?)
app:Delete(route, handler, options?)
ParameterTypeDescription
routestringSupports typed params, wildcards, globs, inline constraints
handlerfunctionSee handler signatures below
options.compressboolean?Enable LZ77 compression on the response (GET/POST only)

Handler Signatures

Two calling conventions are supported. RoExpress detects which one to use automatically based on the number of parameters.

-- Modern (recommended)
function(req, res) end

-- Legacy
function(Player, Payload, req, res) end

In the modern signature req.player and req.raw are populated automatically. In the legacy signature Player and Payload are passed directly as the first two arguments.

Method Conventions

MethodHas body?ResponseEnforced
GEToptionaldata
POSTyesencoded data (Base64 / Deflate)
PUTyesboolean? / nil onlytable body is warned + stripped
DELETEoptionalboolean? / nil onlytable body is warned + stripped

Route Syntax

SyntaxExampleDescription
Literalplayer/coinsExact match
Plain param:nameAny segment → string
Typed param:id=numberCoerced to declared type
Constrained:id(\d+)Must match Lua pattern
Wildcard*One segment → req.captures[n]
Glob**Zero-or-more segments → req.captures[n] as table

Supported Param Types

string · number · int · boolean · vector2 · vector3 · color3 · cframe · Enum.TypeName · Instance

req Object

FieldTypeDescription
req.params{[string]: any}Named params, coerced to declared type
req.captures{any}Positional wildcard/glob captures
req.query{[string]: string}Query string params e.g. ?limit=5
req.dataanyRaw payload from client
req.playerPlayer?The requesting player (modern signature only)
req.rawPayload?Full raw payload table (modern signature only)

res Object

MethodDescription
res:Send(data?)Send success response. PUT/DELETE accept boolean? or nil only — passing a table is warned and stripped. Callable once.
res:Error(message)Send error response. Callable once.
res:Status(code)Set status code. Chainable.

Middleware

app:Use(id, fn)   -- register — return false to block (403), throw for 500
app:Unuse(id)     -- remove by id

Server Push

app:Push(player, event, data)        -- reliable push to one player
app:PushAll(event, data)             -- reliable push to all players
app:PushTo(players, event, data)     -- reliable push to a list

Received on the client via listener:On(event, handler).

Named Ports

app:Listen(name, callback, settings?)  -- create an isolated pipeline
app:GetPort(name)                      -- retrieve a port by name
app:Listen("combat", function(port)
    port:Post("gun/fire/:damage=number", handler)
end, { Max = 30, Refill = 10 })

-- client
local combat = RoExpress("Network", "combat")

Other

app:OnParamError(fn)   -- custom typed param failure handler
app.TokenBucket        -- direct access to the rate limiter
app:Destroy()

Network (Client)

local network = RoExpress("Network")

Callbacks

network:Get(route, data?, callback?, timeout?, retries?)
network:Post(route, data, callback?, timeout?, retries?)
network:Put(route, data, callback?, timeout?, retries?)
network:Delete(route, data?, callback?, timeout?, retries?)

Callbacks and timeout are optional. Omit the callback to block the current thread until the response arrives. All return a requestId.

Promises

network:GetAsync(route, data?, timeout?, retries?)    -- returns Promise
network:PostAsync(route, data, timeout?, retries?)    -- returns Promise
network:PutAsync(route, data, timeout?, retries?)     -- returns Promise
network:DeleteAsync(route, data?, timeout?, retries?) -- returns Promise
network:GetAsync("leaderboard/top")
    :Then(function(res) return res.data.entries end)
    :Then(function(entries) UI:Load(entries) end)
    :Catch(function(err) warn(err.message) end)
    :Finally(function() UI:HideLoader() end)

NetworkResponse

FieldTypeDescription
res.type"response" | "error"Whether the request succeeded
res.statusnumberHTTP-style status code
res.dataany?Payload — decompressed automatically if compressed
res.messagestring?Error message (nil on success)
res.compressedboolean?True if the payload was Deflate-compressed

Other

network:Configure({ maxRetries = 3, backoff = 1 })  -- global retry config (default: 3 retries, 1s backoff)
network:Cancel(requestId)                            -- cancel pending request, returns boolean
network:Destroy()

Retries use exponential backoff starting at backoff seconds (1s → 2s → 4s). Retryable status codes: 408, 429, 500. Non-retryable: 400, 403, 404. The retries? parameter on each method overrides the global config per-request.


Broadcast (Server)

local broadcast = RoExpress("Broadcast")

broadcast:Emit(event, player, data)
broadcast:EmitAll(event, data)
broadcast:EmitTo(event, targets, data)
broadcast:Destroy()

Uses UnreliableRemoteEvent. Subject to per-event and per-player rate limiting. Data cap: 900 bytes.


Listener (Client)

local listener = RoExpress("Listener")

listener:On(event, handler)     -- persistent subscription
listener:Once(event, handler)   -- fires once then unsubscribes
listener:Off(event)             -- remove all handlers for event
listener:Use(id, fn)            -- middleware before every handler
listener:Unuse(id)
listener:Destroy()

Handles both unreliable broadcast and reliable server push through one API.


Bridge (Shared)

local bridge = RoExpress("Bridge")  -- same instance everywhere in this context

bridge.On(name, handler)            -- subscribe; returns Connection
bridge.Once(name, handler)          -- fires once then auto-disconnects; returns Connection
bridge.Emit(name, data?)            -- fire to all handlers on the channel
bridge.Has(name)                    -- returns true if channel has handlers
bridge.Clear(name?)                 -- clear one channel or all channels
bridge.Destroy()                    -- full teardown

-- connections — disconnect a specific handler without clearing the whole channel
local conn = bridge.On("kill", handler)
conn:Disconnect()

-- yieldable variants
bridge.Wait(name, timeout?)                           -- yields until channel fires
bridge.WaitUntil(name, predicate, timeout?)           -- yields until predicate returns true
bridge.WaitFirst(names, timeout?)                     -- yields until any channel fires

Bridge is purely in-process — it does not cross the client/server boundary.


Tamper (Server)

local tamper = RoExpress("Tamper")

tamper.On(handler)                              -- subscribe to detection reports
tamper.AutoKick(threshold, reason?)             -- opt-in auto-kick
tamper.Strike(player, reason?, route?, evidence?) -- manual strike
tamper.GetReport(player)                        -- full player record
tamper.GetStrikes(player)                       -- strike count
tamper.ClearStrikes(player)
tamper.ClearAll()
tamper.SetThresholds(config)

Detection Reasons

ReasonTierTrigger
VERSION_SPOOFimmediateClient version mismatch
MALFORMED_PAYLOADimmediatePayload fails validation
INVALID_PARAMimmediateTyped param coercion fails
UNKNOWN_ROUTEimmediateRoute does not exist
RATE_FLOODpatternRepeated 429s in window
ROUTE_SCANpatternMany distinct unknown routes
PARAM_FLOODpatternRepeated param failures on same route
MANUALimmediateDeveloper called tamper.Strike()

Codec (Shared)

local Codec = require(script.Parent.Codec)

Codec.Compress(data)        -- any → LZ77 base64 string  (compat alias)
Codec.Deflate(data)         -- any → LZH (Deflate) base64 string
Codec.Inflate(str)          -- base64 string → any  (auto-detects LZ77 vs LZH via magic bytes)
Codec.Decompress(str)       -- alias for Inflate
Codec.IsCompressed(str)     -- → boolean

Two compression algorithms over Roblox's native buffer type:

AlgorithmMethodBest for
LZ77Codec.CompressGeneral repetitive data
LZH (Deflate)Codec.DeflateLarger payloads, better ratio

Opt-in per route via { compress = true }. Decompression is automatic on the client — transparent to your callback. Codec.Decompress auto-detects which algorithm was used via magic bytes.

Typical savings: 30–60% on JSON. Not worth enabling under ~500 bytes.


Stream (Shared)

local Stream = RoExpress.Stream

Schema-defined typed binary channels over raw Roblox buffers. No JSON, no Base64. Both server and client define identical channels — no manual numbering, no ordering dependency.

All channels are multiplexed over two shared remotes (StreamUnreliable / StreamReliable).

Quick Start

-- Shared — define the same channels on server and client
local move = Stream.Channel("playerMove", Stream.Schema.New({
    { "pos",   "Vector3" },
    { "vel",   "Vector3" },
    { "state", { "flags", "jumping", "sprinting" } },
}))

Stream.Init()  -- call once, after all Channel() definitions

-- Server: subscribe
move:On(function(data, player)
    print(player.Name, data.pos, data.state.jumping)
end)

-- Client: send
move:Send({
    pos   = hrp.Position,
    vel   = hrp.AssemblyLinearVelocity,
    state = { jumping = false, sprinting = true },
})

Stream.Channel() must be called before Stream.Init(). Define all channels first, then init once.

Channel Options

Stream.Channel(name, schema, {
    reliable      = false,  -- true = RemoteEvent, false = UnreliableRemoteEvent (default)
    maxRate       = 30,     -- max incoming fires/sec per player (server-side)
    onDrop        = fn,     -- called when a packet is rate-limited
    deltaInterval = 10,     -- force a full resync every N delta packets (default 10)
})

Server Send API

channel:SendTo(player, data)           -- one player
channel:SendExcept(except, data)       -- all players except one
channel:Broadcast(data)                -- all clients (FireAllClients)
channel:SendToList(players, data)      -- specific list
channel:SendToDelta(player, data)      -- delta-compressed to one player
channel:BroadcastDelta(data)           -- delta-compressed to all

Client Send API

channel:Send(data)   -- fires to server

Subscribe API (both sides)

local unsub = channel:On(function(data, sender) end)    -- persistent
local unsub = channel:Once(function(data, sender) end)  -- fires once then removes
unsub()  -- unsubscribe at any time

Built-in Types

TypeWire SizeNotes
u8 / u16 / u321 / 2 / 4 BUnsigned integers
i8 / i16 / i321 / 2 / 4 BSigned integers
f32 / f644 / 8 BFloats
bool1 B
string2 + len Bu16 length-prefixed — makes schema variable-size
Vector312 B3× f32
Vector28 B2× f32
Vector3int166 B3× i16
Vector2int164 B2× i16
CFrame28 BPosition + quaternion (Shepperd method)
CFrameLight16 BPosition + Y-yaw only (lightweight)
Color33 BRGB u8
Color3float12 BRGB f32
BrickColor2 Bu16 value
UDim8 B
UDim216 B
Rect16 B
NumberRange8 B
Ray24 BOrigin + Direction as Vector3 pairs
Region324 BAABB min/max corners as Vector3 pairs
PhysicalProperties20 BAll 5 fields as f32
{ "flags", ... }1 BUp to 8 named booleans packed into one byte
{ "enum", EnumType }2 BEnumItem → u16 value

Delta Compression

Only changed fields are sent. Requires a fixed-size schema (no string fields).

-- Fixed-size — eligible for delta
local posSchema = Stream.Schema.New({
    { "pos",   "Vector3" },
    { "state", { "flags", "jumping", "sprinting" } },
})

-- server sends only what changed
channel:SendToDelta(player, newData)
channel:BroadcastDelta(newData)

A full packet is forced on the first send and every deltaInterval packets to recover from unreliable packet loss.

Custom Types

Stream.Types.Register("hp", {
    size  = 2,
    write = function(buf, offset, value) buffer.writeu16(buf, offset, value) end,
    read  = function(buf, offset) return buffer.readu16(buf, offset) end,
})

Other

Stream.GetChannel(name)    -- returns Channel or nil
Stream.GetChannels()       -- full registry table
Stream.Destroy()           -- disconnect remotes and clear state (testing)

TypeCoercer (Shared)

local tc = RoExpress("TypeCoercer")

tc.ToString(value)                          -- any supported type → wire string
tc.FromString(raw, typeName)                -- wire string → Lua value, returns (ok, value)
tc.RegisterInstanceRoot(name, root)         -- register Instance search root

Powers all router param coercion. Use directly when building route URLs dynamically or validating data outside routes.


TokenBucket (Shared)

Accessible via app.TokenBucket.

MethodDescription
tb:Consume(player, cost)Returns false if insufficient tokens
tb:HasTokens(player)Returns true if any tokens remain
tb:HasEnoughTokens(player, cost)Returns true if ≥ cost tokens remain
tb:Grant(player, amount)Add tokens up to Max
tb:GrantAll(amount)Add to all players up to Max
tb:GrantExact(player, amount)Add tokens ignoring Max
tb:GrantAllExact(amount)Add to all ignoring Max
tb:Reset(player)Refill to Max immediately
tb:Destroy()Disconnect events, clear all buckets

Default: Max = 10, Refill = 2 tokens/second.


Base64 (Shared)

local Base64 = RoExpress("Base64")

Base64.Encode("hello")
Base64.Decode("aGVsbG8=")
Base64.EncodeTable({ x = 1 })     -- JSONEncode then base64
Base64.DecodeTable("eyJ4IjoxfQ==") -- base64 then JSONDecode

Request Pipeline

Client fires request
    │
    ├─ 1. Version check         → 400 if mismatch
    ├─ 2. TokenBucket.Consume   → 429 if empty
    ├─ 3. Payload validation    → silent drop if malformed
    ├─ 4. Middleware chain      → 403 if returns false · 500 if throws
    ├─ 5. Router.Match          → 404 if no match
    ├─ 6. Typed param coercion  → OnParamError / 400 on failure
    └─ 7. handler(req, res)     → your business logic

Status Codes

CodeMeaning
200Success
400Version mismatch or invalid typed param
403Blocked by middleware returning false
404No route matched
408Client-side timeout
429Rate limited
500Handler threw / middleware crashed

Typed Accessors

Prerequisite: Advanced typing features require the new Luau type solver. In Roblox Studio, go to Studio Settings → Studio → Script Editor and set LuauTypeCheckMode to Strict (or Default) and enable UseNewLuauTypeChecker. Without this, type inference and exported types will not resolve correctly.

For full Luau type inference, annotate the variable explicitly using the exported types:

local RoExpress = require(path.RoExpress)

-- Server
local app: RoExpress.App = RoExpress("App")

-- Client
local net: RoExpress.Network = RoExpress("Network")

The call form RoExpress("ModuleName") returns any without an annotation. Adding the type annotation gives the Luau solver full inference over every method and field.


Exported Types

local RoExpress = require(path.RoExpress)

-- Envelope types
type Payload           = RoExpress.Payload
type Request           = RoExpress.Request
type Response          = RoExpress.Response
type NetworkPayload    = RoExpress.NetworkPayload
type NetworkResponse   = RoExpress.NetworkResponse
type BroadcastEnvelope = RoExpress.BroadcastEnvelope

-- Handler signatures
type RouteHandlerCompact = RoExpress.RouteHandlerCompact  -- (req, res) -> ()
type RouteHandlerLegacy  = RoExpress.RouteHandlerLegacy   -- (Player, Payload, req, res) -> ()
type RouteHandler        = RoExpress.RouteHandler         -- union of both
type MiddlewareHandler   = RoExpress.MiddlewareHandler
type ParamErrorHandler   = RoExpress.ParamErrorHandler
type NetworkCallback     = RoExpress.NetworkCallback

-- Module instance types
type App             = RoExpress.App
type Network         = RoExpress.Network
type Port            = RoExpress.Port
type Broadcast       = RoExpress.Broadcast
type Listener        = RoExpress.Listener
type Bridge          = RoExpress.Bridge
type BridgeConnection= RoExpress.BridgeConnection
type Hook            = RoExpress.Hook
type Tamper          = RoExpress.Tamper
type TokenBucket     = RoExpress.TokenBucket
type RTTP            = RoExpress.RTTP
type RTTPInstance    = RoExpress.RTTPInstance
type Cross           = RoExpress.Cross
type Promise         = RoExpress.Promise
type Maid            = RoExpress.Maid
type Debounce        = RoExpress.Debounce

-- Tamper sub-types
type TamperReport    = RoExpress.TamperReport
type TamperRecord    = RoExpress.TamperRecord
type TamperReason    = RoExpress.TamperReason
type TamperSeverity  = RoExpress.TamperSeverity

-- Misc
type Method          = RoExpress.Method
type RouteOptions    = RoExpress.RouteOptions
type HookPriority    = RoExpress.HookPriority
type HookOptions     = RoExpress.HookOptions
type TokenBucketSettings = RoExpress.TokenBucketSettings

Limitations

  • Reliable remote data cap — ~50kb per fire
  • Unreliable remote data cap — ~900 bytes per fire (Broadcast)
  • Codec threshold — LZ77 adds overhead; not worth enabling under ~500 bytes
  • Stream interpolation — Stream provides the data, interpolation is up to you
  • Push compressionapp:Push and app:PushAll do not go through Codec

Links


License

MIT — free to use, modify, and distribute.

Package Details

Install command (Click to copy)


Version

2.5.0

License

MIT

check_circle

Safe for commercial use

Automated license review — not legal advice.