webpatser / resonate-users
Signed-in user identity and direct messaging for Resonate: pusher:signin, user channels and a cluster-wide device index
Requires
- php: ^8.5
- illuminate/contracts: ^13.0
- illuminate/support: ^13.0
- webpatser/fledge-fiber: ^13.38
- webpatser/resonate: ^1.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.2
- predis/predis: ^2.2
- pusher/pusher-php-server: ^7.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Signed-in user identity and direct messaging for Resonate.
A Resonate connection has no identity of its own. The only way the server learns who a socket belongs to is the user_id inside a presence channel's channel_data, so everything user-shaped is conditional on the client having joined a presence channel. terminate_connections silently misses anyone who never did, and there is no way at all to push a message to a person rather than to a room.
This plugin implements the Pusher protocol's user authentication instead. A client sends pusher:signin with a signed blob; the server verifies it and the socket carries an identity from then on, whether or not it ever joins a channel.
client Resonate + resonate-users
| pusher:signin {auth, user_data} -> verify HMAC
| remember user 42
| join #server-to-user-42
| <- pusher:signin_success
...
your app: sendToUser(42, 'chat.dm', [...])
| <- chat.dm (every device user 42 has, on every node)
What it gives you
- Direct messages. Address a person, not a room. Every device that person has signed in on receives it, on every node.
- Notifications for rooms a user is not watching. They only have to be connected, not subscribed.
- A working
terminate_connections. Forced logout after a password change reaches users who never joined a presence channel. - An online index. Ask whether someone is connected, and on how many devices, without a metrics round-trip.
The client half already exists
Laravel ships all of it. Broadcast::userRoutes() registers /broadcasting/user-auth, and PusherBroadcaster::resolveAuthenticatedUser() signs the blob with pusher/pusher-php-server. Echo and pusher-js already speak the flow. Until now nothing happened, because Resonate answered pusher:signin with "Unknown Pusher event".
// routes/web.php or a service provider Broadcast::userRoutes(); Broadcast::resolveAuthenticatedUserUsing(function ($request) { return $request->user() ? ['id' => (string) $request->user()->id, 'user_info' => ['name' => $request->user()->name]] : null; });
Installation
composer require webpatser/resonate-users
Register the plugin in config/reverb.php:
'servers' => [ 'reverb' => [ // ... 'plugins' => [ Webpatser\ResonateUsers\UserChannelPlugin::class, ], ], ],
Publish the config if you want to change the Redis connection, the key prefix or the heartbeat:
php artisan vendor:publish --tag=resonate-users-config
Restart the server (php artisan resonate:start, or resonate:reload for a zero-downtime swap).
Sending to a user
A user channel is an ordinary channel name, so anything that can broadcast can reach a person. The Pusher SDK's own helper works:
app('pusher')->sendToUser('42', 'chat.dm', ['body' => 'hello']);
So does a plain broadcast, which is useful when you already have a broadcaster configured:
use Webpatser\ResonateUsers\UserRegistry; $channel = app(UserRegistry::class)->channelFor('42'); // #server-to-user-42
Nothing new carries the message across nodes: the event dispatcher already publishes to every node, and each one delivers to the devices it holds.
Asking who is online
use Webpatser\ResonateUsers\UserRegistry; $users = app(UserRegistry::class); $users->isOnline('42'); // bool $users->deviceCount('42'); // 3 $users->sockets('42'); // ['1234.5678', ...] across every node $users->snapshot(); // ['42' => 3, '7' => 1] for the whole application
On a server with several applications, pass the id: $users->isOnline('42', 'app-two').
Is anything reporting?
An empty index has two readings: nobody is connected, or no socket server is running (or none can reach Redis). Anything that acts on an absence, such as marking a person offline because the index does not list them, has to tell those apart. Every running node refreshes a liveness key on each heartbeat, whether or not anyone is signed in, so you can ask:
$users->hasLiveNode(); // bool: at least one node is reporting $users->liveNodes(); // ['web-1-4242', ...] the nodes currently reporting
if (! $users->hasLiveNode()) { // The index is silent, not empty. Do not treat absence as gone. return; }
A Redis failure throws rather than answering false, so an outage on the reading side is never mistaken for a dead socket server. A node that stops lets its key lapse after ttl seconds, since the plugin contract has no shutdown hook; until then it still counts as live.
Events
| Event | When |
|---|---|
UserSignedIn |
A connection proved an identity. Once per device, not once per person. |
UserSignInRejected |
A signin was refused. Carries a reason constant, so you can alert on a run of INVALID_SIGNATURE without matching on prose. |
UserWentOffline |
A user's last device disconnected anywhere in the cluster. |
UserWentOffline covers a quiet death as well as a clean goodbye: a socket that stops answering is pinged, pruned and closed by the server, and that close reaches the plugin like any other. It is not a "user is gone" guarantee across a reconnect, though. A client that drops and comes back produces an offline followed by a fresh sign-in, so hold the event for a grace period if you need a brief reconnect to pass unnoticed.
Security
- A direct subscribe to a user channel is always refused. A user channel carries no
private-orpresence-prefix, so core would subscribe anyone who asked. Membership is granted by signin and by nothing else. Without this guard one client could read another's direct messages, which is worth knowing even if you do not install this plugin: an application calling the Pusher SDK'ssendToUser()against a bare Resonate is broadcasting to a world-readable channel. - The signature covers the socket id, so a payload lifted from one connection cannot be replayed onto another.
- The key half of
authis checked against the connection's own application, so a token minted for one tenant cannot be presented on another's socket. - The first valid signin wins. A second one is refused rather than allowed to change a live socket's identity.
- A user id never reaches a Redis key raw. It arrives inside a blob the application signed, which proves the application vouched for it and says nothing about its shape, so colons and glob metacharacters are percent-encoded before they form a key.
Configuration
| Key | Default | Purpose |
|---|---|---|
connection |
REDIS_* env |
Redis server holding the index. Takes Laravel's connection keys, scheme (tcp, tls/rediss, unix), read_timeout and the retry keys included. |
key_prefix |
users |
Namespace for every index key. Avoid colons. |
ttl |
90 |
Seconds each node's key lives; refreshed on every heartbeat. |
heartbeat_interval |
30 |
Seconds between reconcile ticks. Keep it well below ttl. |
user_channels |
true |
Whether a signed-in connection joins its own channel. Turning it off records identity but stops delivery. |
Override any of these per environment with RESONATE_USERS_* variables.
How the index stays honest
Membership is written per node, at {prefix}:{appId}:{userId}:{nodeId}, so a node that dies lets its share expire by TTL rather than a live node holding it open. The lifecycle hooks make the fast incremental edits; the heartbeat tick is the authority, rewriting each tracked user's set from the connections this node actually holds. A lost removal therefore self-heals instead of reporting a signed-out person as online forever.
Beside the user sets, each node holds {prefix}-nodes:{appId}:{nodeId} for every application it serves, written at boot and refreshed by the same heartbeat with the same ttl. The suffix sits on the prefix segment, so the key never matches the {prefix}:{appId}:* sweep and is never read as a user.
The index is never consulted when sending. That path is a channel broadcast, which the server already fans out correctly.
Other plugins
If you also run webpatser/resonate-webhooks or webpatser/resonate-channel-meter, use versions that ignore #-prefixed channels. Otherwise every sign-in posts a channel_occupied and, in the meter's case, opens a billable period for what is really a user's session.
Requirements
- PHP 8.5+
- Resonate 1.0+
- A Redis server reachable from every Resonate node
Testing
composer test
The integration suite skips itself unless a Redis server answers on 127.0.0.1:6379.
License
MIT.