peachtree/websocket

A simple, powerful, and easy to use websocket service build on Ratchet

v2.0.0 2020-05-04 10:08 UTC

This package is auto-updated.

Last update: 2024-05-05 01:41:39 UTC


README

pipeline status

This is the websocket library used by Peachtree LLC. This component was initially split off from our main application, but because it helped us so much we are opting to open source it!

Motivation

This library is not a ground-up rebuild of a websocket service, this is simply an abstraction layer on top of cboden/ratchet. If you require even less overhead, it is a fantastic library to build on!

The idea with this package was to provide a very low overhead, powerful, and easy to use framework to build websocket applications on.

Installation

Installation should be fairly straightforward using Composer.

composer require peachtree/websocket

How you implement the directory structure of your project is up to you, but if you need a place to get started this is what I recommend.

.
├── src                    # Your projects source directory
│   ├── Handlers           # Toss all of your message handlers in a folder
│   │   ├── BarHandler.php
│   │   └── FooHandler.php 
│   └── server.php         # The entry point for the server 
└── vendor                 # For 3rd party stuff (like this package)

Project Anatomy

Before we get too far, its important to note down what the various parts of the library are responsible for.

Server

The \Peachtree\Websocket\Server class is the main entrypoint to the application. This class acts as a factory to help set up your websocket server.

Message

A \Peachtree\Websocket\Message object is how the client and server communicate. A message is comprised of three main elements, action, payload, and reference. The \Peachtree\Websocket\MessageFactory factory can be helpful for passing around commonly used messages, like acknowledgements or validation errors.

The action is a string designed to describe the taxonomy of a message. The payload is the body of the message, and is a PHP array cast to a JSON object. The reference is designed to be a string used to help maintain state between the client and server.

Connection Handler

The \Peachtree\Websocket\Connection\Handler class is responsible for managing the various clients connected to the server. You can optionally provide your own message router (described below) if you do not want to use our default one.

Connection State

When a client connects, they are assigned a \Peachtree\Websocket\Connection\State. This has some basic information about the client, including its unique identifier, user-provided metadata, the time of the last message sent, and information on what channels (described below) it is subscribed to.

There is one default piece of metadata provided, and that is the remoteAddress. If we could resolve the remote address of the client then that will be populated, otherwise it will be null. This can be accessed with $state->getMeta()->remoteAddress.

Message Handler

A message handler (a class extending \Peachtree\Websocket\Handler\MessageHandler) is responsible for handling messages. Every incoming message has its action passed into a method in this class that indicates if that handler is responsible for the given message. If so, it will pass the full message and the clients connection state into its "handle" method.

You can see a super simple example of this in action with the built-in \Peachtree\Websocket\Handler\PingPong class.

Multiple handlers can be responsible for any given message. Each handler will yield one or more responses (see next section).

Responses

A \Peachtree\Websocket\IO\Response is a class that can be yielded from a message handler. It will be sent immediately to the client who sent the message you are responding to.

Broadcasts

A broadcast (\Peachtree\Websocket\IO\Broadcast) is type of response that is sent to all clients of a given channel, not necessarily the client who sent the last message.

Sometimes your application needs to do some time-expensive processing to deliver a broadcast, that you might not want to do if nobody is listening to a channel. For these cases you can instead yield a \Peachtree\Websocket\IO\DeferredBroadcast, where the actual message contents will not be generated until the server can ensure that the channel being broadcast to has members.

Router

The provided \Peachtree\Websocket\Routing\Router router is responsible for "routing" messages from a client to the correct message handler.

Usage

Server Type Distinction

You will notice pretty quick that there are two server "modes." The one provided in this example is a plain socket, but a HTTP socket is also available to use.

The difference being, that if you want to consume your application with an application like telnet then you will want to do this:

$server = (new \Peachtree\Websocket\Server('0.0.0.0', 8080))->socket();

If you intend on consuming your websocket API from a JavaScript or web frontend, you will want to use the 'http' method instead.

$server = (new \Peachtree\Websocket\Server('0.0.0.0', 8080))->http();

If you are on Windows, the native Telnet client is kinda terrible so I recommend using PuTTY. If you are using Mac or Linux, the native telnet client will work fine.

Bare Minimum Server

Super easy, right out of the box, you can set up a new server like this:

<?php

require_once 'vendor/autoload.php';

// Listen on 0.0.0.0 on port 8080
$server = (new \Peachtree\Websocket\Server('0.0.0.0', 8080))->socket();
$server->run();

If you run that, you can then connect to the server via telnet.

$ telnet localhost 8080

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
 > test
 < {"ref":null,"action":"error","payload":{"message":"Syntax error"}}

If you type in something and press enter, say test, it should give you a response saying your had a syntax error in your message since it was not properly formatted.

{"ref":null,"action":"error","payload":{"message":"Syntax error"}}

If you got that, then its working! Without further setup you wont be able to do much though.

Ping Pong

Say you wanted to send a message to the server and get a response. In that case you will need to register a Message Handler. Luckily these are very easy to build, and we have a few built in!

<?php

require_once 'vendor/autoload.php';

// Register the "PingPong" message handler with the message router
\Peachtree\Websocket\Routing\Router::addMessageHandler(
    new \Peachtree\Websocket\Handler\PingPong()
);

// Listen on 0.0.0.0 on port 8080
$server = (new \Peachtree\Websocket\Server('0.0.0.0', 8080))->socket();
$server->run();

Now connect with your local telnet client. If you send a "ping" message, you will get a "pong" response.

$ telnet localhost 8080

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
 > {"action":"ping","payload":{},"ref":null}
 < {"ref":null,"action":"pong","payload":[]}

Chat Server

Say we wanted to make a simple chat service, where users can send messages to a channel they are in.

Doing this will require building our own message handler. Lets write one!

<?php

declare(strict_types=1);

require_once 'vendor/autoload.php';

use Peachtree\Websocket\Connection\State;
use Peachtree\Websocket\Handler\MessageHandler;
use Peachtree\Websocket\IO\Broadcast;
use Peachtree\Websocket\IO\Response;
use Peachtree\Websocket\Message;
use Peachtree\Websocket\MessageFactory;
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Rules\Key;
use Respect\Validation\Rules\StringType;
use Respect\Validation\Validator;

final class ChatManager extends MessageHandler
{

    /**
     * @inheritDoc
     */
    protected function handle(Message $message, State &$state): Generator
    {
        // First things first, lets validate our payload.
        // We need to know what the message is, and what channel to send to.
        // This library uses the Respect/Validation package
        try {
            (new Validator())
                ->addRule(new Key('channel', new StringType(), true))
                ->addRule(new Key('message', new StringType(), true))
                ->assert($message->getPayload());
        } catch (NestedValidationException $e) {
            // This message was invalid. Lets send them a response letting them know why.
            yield new Response(
                MessageFactory::make($message->getRef())->validationException(
                    $e->getMainMessage(),
                    ...$e->getMessage()
                )
            );
            return;
        }

        // Now that we know the message passes validation, lets broadcast the message to the channel!
        yield new Broadcast($message, $message->getPayload()['channel']);

        // Lets tell the sender that the message has been sent
        yield new Response(
            MessageFactory::make($message->getRef())->acknowledge('Your message has been sent!')
        );
    }

    /**
     * @inheritDoc
     */
    public function shouldHandle(string $messageAction): bool
    {
        // We only care if someone is trying to "say" something
        return $messageAction === 'say';
    }
}

Now that we have our message handler written, lets start up a new server and register the handler!

<?php

require_once 'vendor/autoload.php';

// Register both the default channel manager and our own chat manager
\Peachtree\Websocket\Routing\Router::addMessageHandler(
    new \Peachtree\Websocket\Handler\ChannelManager(),
    new ChatManager()
);

// Listen on 0.0.0.0 on port 8080
$server = (new \Peachtree\Websocket\Server('0.0.0.0', 8080))->socket();
$server->run();

Lets open up two telnet windows now. In the first window, we want to subscribe to a channel.

$ telnet localhost 8080

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
 > {"action":"channel","payload":{"subscribe":["My Super Channel"],"unsubscribe":[]},"ref":"My Reference"}
 < {"ref":"My Reference","action":"ack","payload":{"message":"Subscribed to 1 and unsubscribed from 0 channels."}}

In the next window, lets send a message to that channel.

$ telnet localhost 8080

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
 > {"action":"say","payload":{"channel":"My Super Channel","message":"Hello World!"},"ref":"foo reference"}
 < {"ref":"foo reference","action":"ack","payload":{"message":"Your message has been sent!"}}

Back in the first window, you can see the message showed up!

 < {"ref":"foo reference","action":"say","payload":{"channel":"My Super Channel","message":"Hello World!"}}

Multi-Message Encoding

Sometimes your application may need to send multiple messages at once. You can send them each one at a time, but you can also help to reduce the server message handling overhead by packing them all into a single message as an array.

For example, instead of this:

 > {"action":"foo","payload":{},"ref":"foo ref"}
 < {"action":"ack","payload":{},"ref":"foo ref"}
 > {"action":"bar","payload":{},"ref":"bar ref"}
 < {"action":"ack","payload":{},"ref":"bar ref"}

You can do this:

 > [{"action":"foo","payload":{},"ref":"foo ref"},{"action":"bar","payload":{},"ref":"bar ref"}]
 < {"action":"ack","payload":{},"ref":"foo ref"}
 < {"action":"ack","payload":{},"ref":"bar ref"}

Application Middleware

Middleware is a handy tool to use to interact with both the message input and the message output, while in the same context. Middleware is ran for every message input, regardless of if any response is warranted.

To create a middleware class, simply implement the \Peachtree\Websocket\Middleware\Middleware interface into your class and register it with the application router like so:

/** @var \Peachtree\Websocket\Middleware\Middleware $myMiddleware */
Peachtree\Websocket\Routing\Router::addMiddleware($myMiddleware);

The middleware requires an __invoke() method. If you just want to run code before or after responses are sent to the client, you can do so with this:

use Peachtree\Websocket\Connection\State;
use Peachtree\Websocket\Message;
use Peachtree\Websocket\Middleware\Middleware;

class MyMiddleware implements Middleware
{
    public function __invoke(Generator $responses, Message $input, State &$state): Generator {
        // Before $input is parsed
        foo();
        
        // parse the $input and send $responses back to the client
        yield from $responses;
    
        // after the messages have been sent
        bar();
    }
}

For a more advanced usage example, check out the \Peachtree\Websocket\Middleware\Debugger file. It is a built-in middleware that prints out the messages being sent and received in real-time.

Error Handling

By default, a message handler throws an exception the connection will be closed, and the exception ignored. If you want to add custom logging/reporting functionality, you can add a callback to the server router.

use Exception;
use Peachtree\Websocket\Connection\State;
use Peachtree\Websocket\Routing\Router;

Router::addErrorHandler(
    function (Exception $e, State $state): void {
        // However you want to log/report the exception.
        // Also passing the connection state to help troubleshoot.    
    }
);

Channel Manager Footnote

To restrict what channels any given connection may subscribe to, you can provide your own optional callback. This callback accepts two arguments, the channel name and the connection state object.

Example

use Peachtree\Websocket\Connection\State;
use Peachtree\Websocket\Handler\ChannelManager;
use Peachtree\Websocket\Routing\Router;

Router::addMessageHandler(
    new ChannelManager(
        static function (string $channel, State $state): bool {
            return 
                in_array($channel, ['channel 1', 'channel 2']) && 
                $state->getMeta()->whatever;
        }
    )
);