LibxaSocket 0.1.0: realtime that Laravel Echo already speaks
The package had a wire format of its own, and its own notes recorded the cost: Laravel Echo could not talk to it. It now implements the Pusher protocol on ReactPHP — the stack Reverb actually uses, which is what checking rather than assuming turned up. Plus the signature detail that fails in the worst possible way if you get it wrong.
libxa/socket 0.1.0 is out: a WebSocket server for LibxaFrame that speaks the
Pusher protocol.
The package existed before this release. It was built on Workerman, with a wire format of its own, and its own notes recorded what that cost — worth quoting, because it is honest about it:
I did not reimplement the full Pusher wire protocol, which would break Laravel Echo on the client side.
That single sentence is the reason for the rewrite.
What a private protocol actually costs
A server with its own wire format needs its own client. Writing that client is not the hard part — the hard part is everything after it works on the happy path.
Reconnection with backoff, so a server that goes down does not get hammered by every open tab at ten requests a second. Channel state across a reconnect, so a client that drops re-subscribes to what it was in rather than going silent. Presence membership that survives the same. Every browser's idea of when a socket is really dead, which is not the same as when the network says so.
All of that already exists in pusher-js, exercised by a very large number of
people over a decade, and none of it can be used unless the bytes on the wire
match.
So now they do. pusher:connection_established, pusher:subscribe,
pusher_internal:subscription_succeeded, member_added, member_removed,
client-* events — the lot.
Echo.join(`room.${roomId}`)
.here(users => console.log(users))
.joining(user => console.log(user.name, 'joined'))
.listen('MessagePosted', e => console.log(e.body));
Nothing in that snippet knows what server it is talking to.
Checking rather than assuming
The instruction that started this was specific: find out whether Laravel Reverb uses Workerman, and use it only if it does.
It does not. Reverb's composer.json requires react/socket,
ratchet/rfc6455, guzzlehttp/psr7 and clue/redis-react. There is no
Workerman in it anywhere.
So this is on ReactPHP now: react/socket for the event loop and the listener,
ratchet/rfc6455 for the handshake and the frame codec. Writing either by hand
would have meant a second implementation of a specification that already has a
good one, maintained by people who care about the parts nobody thinks about
until a proxy mangles a frame.
Presence is about people, not sockets
The roster is the feature people actually see, and the thing that goes wrong with it is counting the wrong noun.
One user with two tabs open is one member of a room. Opening the second tab
announces nothing. Closing it announces nothing. Only the last connection
leaving sends member_removed.
Every presence implementation has this bug first, and it is the one users notice, because "3 people online" being wrong is visible in a way most bugs are not. There are three tests pinning it, named after the situation rather than the method:
test_the_same_user_twice_counts_once
test_closing_one_of_two_tabs_does_not_announce_a_departure
test_closing_the_last_tab_does_announce_a_departure
The signature detail that matters most
Private and presence channels are joined with a signature your application
mints. For presence, the signed string includes channel_data — the JSON
describing who the member is.
The temptation is to decode that JSON, verify against the decoded value, and move on. It does not work, and it fails in the worst possible way.
Re-encoding produces different bytes: different key order, different unicode escaping, different slash escaping. So a signature that is entirely correct fails to verify. And the natural response to signatures failing for no visible reason is to stop checking them.
It is verified byte-for-byte exactly as the client sent it. There is a test
whose name is the explanation:
test_channel_data_is_verified_exactly_as_sent, and next to it
test_tampered_channel_data_is_refused — because without the second one, the
first is just a signature that always passes.
The signature also covers the socket id, so one minted for a connection cannot be replayed by another. A token leaked out of one browser is useless from another.
A channel with no rule is refused
routes/channels.php decides who may listen:
$channel->register('orders.{orderId}', fn ($user, string $orderId): bool =>
Order::find($orderId)?->user_id === $user->id);
A private or presence channel with no rule registered is refused. Not allowed-by-default, not logged-and-allowed. Refused.
The alternative permits what nobody has written a rule for, which means every private channel is public until somebody remembers it exists — and nothing tells you which ones those are. Failing closed is noisier on the first day and correct on all the others.
Two framework bugs found on the way
Building a package against a framework is the best test that framework gets, and this one turned up two things that had never worked at all.
broadcast(new SomethingHappened) was a fatal error. The helper called
BroadcastManager::send(), a method that has never existed on that class.
Every documented use of the helper raised Call to undefined method. Nothing
has ever been broadcast through it.
No package could register a broadcaster. A driver had to be a
create<Name>Driver method on BroadcastManager, so the only way to add one
was to edit the framework — which made broadcasting the single subsystem a
package could not extend, and a realtime package the obvious thing that could
not be written. There is now a BroadcastManager::extend().
Both are in framework 0.11.2.
What is not there
One process, holding every connection and every channel in memory. Two processes do not share channels, so a client connected to one will not receive an event published through the other.
For a single server that is usually fine — ReactPHP handles thousands of connections in one process and the work per message is small. Beyond that you need a shared backplane, which this does not have yet. Reverb solves it with Redis pub/sub, and the same approach fits here; it is the obvious next thing to build.
It also speaks ws://, not wss://, so in production it goes behind a proxy
that terminates TLS. The package page has the nginx block,
including the proxy_read_timeout line everybody forgets — the default is 60
seconds, and a WebSocket that is merely quiet looks exactly like one that has
stalled.
Try it
examples/chat in the repository is a working room: presence, live messages
and typing indicators, written against the raw protocol rather than Echo, so
every message the format involves is visible in one file. That is a better
introduction to what is actually happening than a library that hides it.
Full documentation is on the package page.