Middleware
Middleware is a function that sees the packets a carrier sends and receives. A packet is one message in flight: an event name plus the arguments that went with it. Middleware can look at both, and it can change both.
The two hooks
A carrier has two chains. One for packets coming in, one for packets going out. Both methods are shared code, so you can call them on the server, on the client, or on both. A chain belongs to the carrier you called it on and to nothing else.
UseIncoming Shared
carrier:UseIncoming(Callback: MiddlewareFunction) -> ()
Adds a function to the end of the inbound chain. It runs for each packet this carrier
receives, before your On listeners or your When responder
see it. Replies to a Call are the one exception, and they are covered
further down.
| Parameter | Type | What it is |
|---|---|---|
Callback | MiddlewareFunction | The middleware. Its shape is below, and the type lives in Types. |
Returns nothing.
local shop = Pigeon.new("Shop")
shop:UseIncoming(function(direction, event, args, player)
print("in", event, #args)
end)
UseOutgoing Shared
carrier:UseOutgoing(Callback: MiddlewareFunction) -> ()
Adds a function to the end of the outbound chain. It runs for each packet this carrier
sends, just before the packet is handed to the transport. The value a
When responder returns to answer a request does not count as a send here.
| Parameter | Type | What it is |
|---|---|---|
Callback | MiddlewareFunction | The middleware. Its shape is below, and the type lives in Types. |
Returns nothing.
local shop = Pigeon.new("Shop")
shop:UseOutgoing(function(direction, event, args, player)
print("out", event, #args)
end)
The callback
Every middleware function has the same shape.
(direction: string, event: string, args: {any}, player: Player?) -> (string?, {any}?)
| Argument | What it is |
|---|---|
direction | The string "incoming" or "outgoing". Handy when you use the same function for both chains. |
event | The event name, as you wrote it. No channel prefix, no decoration. |
args | A plain array of the arguments. Index 1 is the first one. |
player | The player on the other end, or nil. See the table below. |
When you get a player
player is only ever filled in on the server, and only on some paths.
Do not assume it is there.
| Where | What player is |
|---|---|
| Incoming, on the server | The player who sent the packet. |
| Incoming, on the client | nil. It came from the server. |
Outgoing, CallTo | The player being asked. |
Outgoing, Revoke or a refused handshake | The player losing access, on the reserved __pigeon_table_revoke message it sends them. |
Outgoing, Broadcast, BroadcastTo, BroadcastExcept, SendToRoom | nil. One packet is built for the whole list, so there is no single recipient to name. |
| Outgoing, on the client | nil. There is only one place to send to. |
What you return
Two optional values, in this order.
| Return | Effect |
|---|---|
| A string | Replaces the event name. |
| A table, second | Replaces the argument list. |
| Nothing | The packet carries on untouched. |
Each is checked on its own. Return only a string and the arguments are left alone.
Return nil then a table and the name is left alone. Anything that is
not a string in the first slot, or not a table in the second, is ignored.
Where in the trip it runs
Outgoing middleware runs just before the packet is handed to the transport, after the handshake filter has already narrowed the recipient list.
Sending
Incoming middleware runs after the handshake check and before your listeners.
Receiving
Two things follow from that. A client the handshake turned away never reaches your incoming middleware. And if a broadcast has nobody left to send to after the handshake filter, the outgoing chain does not run at all. See Handshakes.
Replies skip both chains
The value a When responder returns to answer a Call or a
CallTo does not go through middleware. A reply travels back on its own path, so the outgoing
chain on the answering machine never sees it, and neither does the incoming chain on
the asking machine. Middleware only ever sees the request half of a round trip.
See Requests and Replies.
The chain runs in order
Middleware runs in the order you added it. Each one is handed what the previous one produced, so a rename in the first is what the second sees.
local carrier = Pigeon.new("Demo")
carrier:UseOutgoing(function(direction, event, args)
print("first sees", event) --> Buy
return "Purchase"
end)
carrier:UseOutgoing(function(direction, event, args)
print("second sees", event) --> Purchase
end)
Whatever comes out of the last one is what actually gets sent, or what your listeners actually get.
The chain is read at the moment a packet moves, so it does not matter whether you
call UseIncoming before or after On. There is no way to
remove a middleware once added, short of destroying the carrier.
Example: log every event
The simplest useful middleware. It returns nothing, so nothing changes.
local shop = Pigeon.new("Shop")
shop:UseIncoming(function(direction, event, args, player)
local who = if player then player.Name else "server"
print(string.format("[%s] %s from %s, %d args", direction, event, who, #args))
end)
shop:UseOutgoing(function(direction, event, args, player)
print(string.format("[%s] %s, %d args", direction, event, #args))
end)
Because both chains get direction, you can write it once and hand the
same function to both.
local function log(direction, event, args, player)
print(direction, event, #args)
end
shop:UseIncoming(log)
shop:UseOutgoing(log)
Example: add a timestamp argument
Push a value onto the end of args and hand the table back as the second
return. The first return is nil, so the event name is untouched.
local chat = Pigeon.new("Chat")
chat:UseOutgoing(function(direction, event, args, player)
table.insert(args, os.time())
return nil, args
end)
chat:Emit("Say", "hello")
local chat = Pigeon.new("Chat")
-- The extra argument lands after the ones the client passed.
chat:On("Say", function(player, text, sentAt)
print(player.Name, text, sentAt)
end)
The args table you get is a copy, so you can edit it in place and
return it. You do not have to build a new one.
Careful
You can add arguments, but you cannot make a packet shorter than the caller
made it. Pigeon sends whichever is larger: the number of arguments the caller
passed, or the length of the table you returned. Removing an entry just turns
it into a nil in the middle.
Adding to a list that contains nil
table.insert puts the value at #args + 1, and the length
of a table with a gap in it is not defined. If the caller passed a
nil, table.insert can land on the gap and overwrite it
instead of adding to the end.
chat:Emit("Say", "hello", nil, "world")
chat:UseOutgoing(function(direction, event, args)
-- Not this. The gap at 2 makes the position unreliable.
table.insert(args, os.time())
-- Do this instead. Say where it goes.
args[4] = os.time()
return nil, args
end)
If your channel never sends nil, table.insert is fine.
If it might, count the arguments yourself and write to that index. See
Sending and Receiving for how nil travels.
Example: rename an event
Return a string and the packet travels under that name instead. On the sending side this is straightforward. It changes the name that goes on the wire.
local shop = Pigeon.new("Shop")
-- Old code all over the place still says "Buy". The server only knows "Purchase".
shop:UseOutgoing(function(direction, event, args, player)
if event == "Buy" then
return "Purchase"
end
end)
shop:Emit("Buy", "sword") -- the server sees "Purchase"
On the receiving side the rename decides which listeners fire. The packet arrives
as "Buy", middleware turns it into "Purchase", and the
handlers registered for "Purchase" run. Handlers registered for
"Buy" do not.
local shop = Pigeon.new("Shop")
shop:UseIncoming(function(direction, event, args, player)
if event == "Buy" then
return "Purchase"
end
end)
shop:On("Purchase", function(player, itemId)
giveItem(player, itemId)
end)
Nothing calls On("Buy") and nothing needs to. The old name only has to
exist on the wire.
Every packet on the channel runs the chain
Incoming middleware runs for every packet addressed to the carrier, whether or not anything is listening for the event it names. You do not have to bind a name to see it, so you can rename an event nobody listens for onto one somebody does, log the events you did not expect, or turn a packet away before it reaches anything.
local shop = Pigeon.new("Shop")
shop:UseIncoming(function(direction, event, args, player)
-- Runs for "Buy", for "Purchase", and for a name a client made up.
print(player, event)
end)
shop:On("Purchase", handlePurchase)
Note
A packet whose event has no listener yet is held for one, the same as it always was, so a handler you register a moment late still gets it. It runs the chain once, when it arrives, not again when it is replayed.
Renaming to nothing turns the packet away
Return a name nothing is listening for and the packet stops there. No listener runs and nothing is held for later. That is how you refuse a packet: send it somewhere that does not exist.
local shop = Pigeon.new("Shop")
shop:UseIncoming(function(direction, event, args, player)
if isRateLimited(player) then
return "DENIED"
end
end)
shop:On("Purchase", handlePurchase) -- never runs for a limited player
"DENIED" is not special. It is just a name you never call
On for, and any other such name does the same thing.
A Call refused this way is answered straight away with
nil. The caller does not sit out its timeout, because a rename is a
decision rather than a listener that has yet to appear. See
Requests and Replies.
Careful
If you build two carriers on one name and only one of them binds an event, the other's incoming middleware does not see that event: the packet is claimed by the listener before the chain gets a look. Keep the receiving side to a single carrier per name, which is the advice on Carriers anyway.
An error in middleware does not stop the packet
Every middleware runs guarded. If yours throws, Pigeon prints a warning naming the direction and the event, discards whatever that one would have returned, and moves on to the next in the chain. The packet still arrives.
local carrier = Pigeon.new("Demo")
carrier:UseIncoming(function(direction, event, args, player)
error("oops")
end)
carrier:UseIncoming(function(direction, event, args, player)
print("still runs")
end)
-- Your On handler still fires with the original arguments.
This is deliberate. A logging function with a typo in it should not take your whole channel down. It does mean a broken middleware fails quietly apart from the warning, so read your output window.
Middleware sees Pigeon's own events
Pigeon runs a few things over the same channel: pings, the handshake, and staged
table snapshots and patches. They use event names that start with
__pigeon_, and they go through your middleware like anything else.
Log a busy channel and you will see names like these:
| Event | What it is |
|---|---|
__pigeon_ping | A Ping round trip. |
__pigeon_handshake | A client presenting credentials. |
__pigeon_table_request | A client asking for a staged table. |
__pigeon_table_snapshot | A full copy of a staged table, pushed out by ForceTable. |
__pigeon_table_patch | A batch of staged table writes. |
__pigeon_table_release | A client giving a staged table up. |
__pigeon_table_revoke | The server taking staged tables off a client, sent by Revoke. |
Warning
Never rename or rewrite an event whose name starts with __pigeon_.
You will break handshakes, pings or staged table replication on that channel.
If your middleware changes things, check the name first.
local carrier = Pigeon.new("Demo")
carrier:UseOutgoing(function(direction, event, args, player)
if string.sub(event, 1, 9) == "__pigeon_" then
return -- leave Pigeon's own traffic alone
end
table.insert(args, os.time())
return nil, args
end)
The same prefix is off limits for your own event names. See Sending and Receiving.
Middleware is per carrier
A chain belongs to one carrier object, not to a channel name. Two carriers built on
the same name have separate chains, even though they hear the same packets.
Pigeon.new("Shop") with no options hands back the same cached carrier
every time, so middleware added anywhere in your code applies everywhere that asks
for "Shop". Passing options builds a fresh carrier with an empty chain.
See Carriers.
Destroy clears both chains
carrier:Destroy() empties the incoming and outgoing lists along with
everything else. Middleware holds your closures, and those closures can hold
anything, so they are dropped rather than left sitting on a dead object. See
Cleanup.
What to read next
- Handshakes if you want to block a client outright rather than filter its packets.
- Sending and Receiving for the methods middleware sits in front of.
- Carrier for the full method list.