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.

ParameterTypeWhat it is
CallbackMiddlewareFunctionThe 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.

ParameterTypeWhat it is
CallbackMiddlewareFunctionThe 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}?)
ArgumentWhat it is
directionThe string "incoming" or "outgoing". Handy when you use the same function for both chains.
eventThe event name, as you wrote it. No channel prefix, no decoration.
argsA plain array of the arguments. Index 1 is the first one.
playerThe 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.

WhereWhat player is
Incoming, on the serverThe player who sent the packet.
Incoming, on the clientnil. It came from the server.
Outgoing, CallToThe player being asked.
Outgoing, Revoke or a refused handshakeThe player losing access, on the reserved __pigeon_table_revoke message it sends them.
Outgoing, Broadcast, BroadcastTo, BroadcastExcept, SendToRoomnil. One packet is built for the whole list, so there is no single recipient to name.
Outgoing, on the clientnil. There is only one place to send to.

What you return

Two optional values, in this order.

ReturnEffect
A stringReplaces the event name.
A table, secondReplaces the argument list.
NothingThe 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

shop:Broadcast("Sold", id) handshake filter outgoing middleware on the wire

Incoming middleware runs after the handshake check and before your listeners.

Receiving

packet arrives handshake check incoming middleware your On handlers

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.

Server
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.

Client
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")
Server
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.

Client
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.

Client
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.

Server
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:

EventWhat it is
__pigeon_pingA Ping round trip.
__pigeon_handshakeA client presenting credentials.
__pigeon_table_requestA client asking for a staged table.
__pigeon_table_snapshotA full copy of a staged table, pushed out by ForceTable.
__pigeon_table_patchA batch of staged table writes.
__pigeon_table_releaseA client giving a staged table up.
__pigeon_table_revokeThe 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