lighthouse icon indicating copy to clipboard operation
lighthouse copied to clipboard

Support beyondcode/laravel-websockets as a subscription driver

Open nateajohnson opened this issue 5 years ago • 26 comments

Hello

I've been trying to get beyondcode/laravel-websockets to work with lighthouse but keep running into this error.

Error during WebSocket handshake: Unexpected response code: 426

Then I stumbled across this comment that says it is incompatible with lighthouse. Any thoughts on this? I'd love to be able to use both. I saw there was a lighthouse websockets project published last year, but it doesn't seem to have much action at the moment, which is why I looked into using the beyondcode server.

https://github.com/beyondcode/laravel-websockets/issues/111#issuecomment-505349459

Thanks, Nate

nateajohnson avatar Jul 03 '19 18:07 nateajohnson

For implementing this, I think we need to implement the handler from laravel websockets, so we can close the subscriptions. https://docs.beyondco.de/laravel-websockets/1.0/advanced-usage/custom-websocket-handlers.html

olivernybroe avatar Jul 17 '19 08:07 olivernybroe

I get it working, I will try to make a repo (with lighthouse v3, since there are issues with subscriptions in v4), but so far, let me copy-paste the code:

In config/websockets.php, replace: 'channel_manager' => \App\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class,

App\WebSockets\Channels\ChannelManagers\ArrayChannelManager.php:

<?php

namespace App\WebSockets\Channels\ChannelManagers;

use BeyondCode\LaravelWebSockets\WebSockets\Channels\Channel;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Subscriptions\Contracts\StoresSubscriptions as Storage;
use Ratchet\ConnectionInterface;

class ArrayChannelManager extends \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager
{
    public function removeFromAllChannels(ConnectionInterface $connection)
    {
        $storage = app(Storage::class);

        collect(Arr::get($this->channels, $connection->app->id, []))
            ->each(function (Channel $channel, string $channelName) use ($storage) {
                $storage->deleteSubscriber($channelName);
            });

        parent::removeFromAllChannels($connection);
    }
}

In AppServiceProvider, register your own Router:

public function register()
    {
        $this->app->singleton('websockets.router', function () {
            return new \App\WebSockets\Server\Router();
        });
    }

that Router should be:

<?php

namespace App\WebSockets\Server;

use App\WebSockets\WebSocketHandler;
use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelController;
use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelsController;
use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchUsersController;
use BeyondCode\LaravelWebSockets\HttpApi\Controllers\TriggerEventController;

class Router extends \BeyondCode\LaravelWebSockets\Server\Router
{
    public function echo()
    {
        $this->get('/app/{appKey}', WebSocketHandler::class);
        $this->post('/apps/{appId}/events', TriggerEventController::class);
        $this->get('/apps/{appId}/channels', FetchChannelsController::class);
        $this->get('/apps/{appId}/channels/{channelName}', FetchChannelController::class);
        $this->get('/apps/{appId}/channels/{channelName}/users', FetchUsersController::class);
    }
}

The App\WebSockets\WebsocketHandler should be:

<?php

namespace App\WebSockets;

use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Nuwave\Lighthouse\Subscriptions\Contracts\StoresSubscriptions as Storage;
use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\MessageInterface;

class WebSocketHandler extends \BeyondCode\LaravelWebSockets\WebSockets\WebSocketHandler
{
    public function onMessage(ConnectionInterface $connection, MessageInterface $message)
    {
        if ($message->getPayload()) {
            $payload = json_decode($message->getPayload(), true);

            $eventName = Str::camel(Str::after(Arr::get($payload, 'event'), ':'));

            if ($eventName === 'unsubscribe') {
                $storage = app(Storage::class);

                $storage->deleteSubscriber(
                    Arr::get($payload, 'data.channel')
                );
            }
        }

        parent::onMessage($connection, $message);
    }
}

It works fine for me, but I'm having this issue: https://github.com/beyondcode/laravel-websockets/issues/163 , but maybe it is from laravel-websockets itself.

enzonotario avatar Jul 25 '19 16:07 enzonotario

Hello @enzonotario thanks for your effort but can you please explain the steps in details? how can we make websocket which listen to subscription like ws:\link:port\subscription

gehad17 avatar Jul 25 '19 19:07 gehad17

@gehad17 firstly, try to get it working with Pusher, folllowing the docs. After that, you will be able to get it working with laravel-websockets. Try with lighthouse v3, since v4 have some issues related to subscriptions.

enzonotario avatar Jul 25 '19 22:07 enzonotario

Hey @enzonotario , I tried using your implementation as is but I keep getting an error when I try connecting to the graphql endpoint. Using the debugger, I find TypeError: Return value of Nuwave\Lighthouse\Execution\BaseRequest::query() must be of the type string, null returned. Have you encountered such error ?

lukadriel7 avatar Sep 04 '19 00:09 lukadriel7

@lukadriel7 That error seems to be when you reach the endpoint without a query. I mean, if I enter via a browser to localhost:8000/graphql (without passing the query as a query param), I get that error. So, how are you testing this? have you setup the Apollo Link?

enzonotario avatar Sep 04 '19 13:09 enzonotario

@enzonotario I am working on the back end of my application for now, so I am using the graphql playground to try the subscriptions.

lukadriel7 avatar Sep 04 '19 13:09 lukadriel7

I have never use the playground, and from this comment it seems that won't work (https://github.com/nuwave/lighthouse/issues/750#issuecomment-485798128). So if you really want to test it, you have to setup the client implementation.

enzonotario avatar Sep 04 '19 14:09 enzonotario

Thanks, I will try it as soon as possible and let you know.

lukadriel7 avatar Sep 04 '19 14:09 lukadriel7

Hi folks! I could create an example: https://github.com/enzonotario/lighthouse-laravel-websockets . Sorry for the additional boilerplate (Inertia), but so far is just the only way I know to use Vue (and for me it's just a copy-paste from other projects). The EchoLink is just a copy from one I had in an Angular project (based in Lighthouse's docs), so maybe it has to be improved for Vue.

enzonotario avatar Sep 08 '19 19:09 enzonotario

Thank you very much. I will check it

lukadriel7 avatar Sep 10 '19 02:09 lukadriel7

Does anyone know offhand, is there still issues in v4 with subscriptions?

kdevan avatar Mar 24 '20 17:03 kdevan

If people aren't using beyondcode/laravel-websockets with this package, then can anyone reccomend a package/how to do websockets?

joshhornby avatar Apr 08 '20 19:04 joshhornby

If people aren't using beyondcode/laravel-websockets with this package, then can anyone reccomend a package/how to do websockets?

I think the easiest way is to use pusher, since the subscriptions were implemented with pusher in mind. Not sure if It will change soon. You could read the code in this repository and try to adapt your application.

lukadriel7 avatar Apr 08 '20 21:04 lukadriel7

Hi! I followed exactly the steps in @enzonotario 's tutorial, but i get an error message. Does anyone have any idea what am i missing here? Capture

jozsefvamos avatar Apr 16 '20 14:04 jozsefvamos

Are you sending your request to graphql/subscriptions/auth? The response body isn't revealing much except that your request seems to be missing Lighthouse's router and controller. That should be returning JSON with a token like this: {"auth":"171ab1b4008b4b48743d:7af46076a36531cb0f68127c636d30cfec8e0f18f07886f258631588fb93df50"}

DonovanChan avatar Apr 16 '20 14:04 DonovanChan

@DonovanChan yes, of courese. Here is the constructor from EchoLink

constructor() {
		super();
		const token = AUTH_TOKEN();
		window.Pusher = require('pusher-js');
		Pusher.logToConsole = true;
		this.subscriptions = [];
		this.echo = new Echo({
			broadcaster: 'pusher',
			key: process.env.MIX_PUSHER_APP_KEY,
			cluster: process.env.PUSHER_APP_CLUSTER,
			authEndpoint: 'graphql/subscription/auth',
			wsHost: window.location.hostname,
			wsPort: 6001,
			wssPort: 6001,
			disableStats: true,
			enabledTransports: ['ws', 'wss'],
			auth: {
				headers: {
					authorization: token ? `Bearer ${token}` : '',
				},
			},
		});
	}

jozsefvamos avatar Apr 16 '20 14:04 jozsefvamos

It looks like you have a typo in your config: authEndpoint should have "subscriptions" with an "s". To verify, try copying your existing endpoint and comparing it to your routes:

php artisan route:list | grep "graphql/subscription/auth"

It should give you a result like this when it's correct:

|        | POST          | graphql/subscriptions/auth                               | lighthouse.subscriptions.auth    | Nuwave\Lighthouse\Support\Http\Controllers\SubscriptionController@authorize                         |

DonovanChan avatar Apr 16 '20 15:04 DonovanChan

Ah ok, i didn't see that :-) I fixed it, but unfortunately i still get the same error.

jozsefvamos avatar Apr 16 '20 16:04 jozsefvamos

Your response to the route:list command should show you the controller. That's a good place to start.

DonovanChan avatar Apr 16 '20 17:04 DonovanChan

@DonovanChan Thank you for your help! :-) i figured it out... Don't ask me why, but it wasn't enough to say that the authEndpoint is graphql/subscriptions/auth I tried authEndpoint: http: //127.0.0.1:8000/graphql/subscriptions/auth, and now it works :-)

jozsefvamos avatar Apr 16 '20 18:04 jozsefvamos

So I have been working hard to get subscriptions on a level I am happy with in my application and have come up with the following solution I'd like to get you feedback on if possible.

Note: This code assumes that laravel-websockets is installed in the same application codebase as lighthouse, however you can still run php artisan websockets:serve on another server.

Note: This code assumes you are using a shared caching solution like Redis for your subscriptions storage, a slower cache solution (like file) might result in your websockets server to perform badly and or cause race conditions updating the subscriptions storage.

Note: This code was written with PHP 7.4 in mind, this will work fine on most PHP versions, you'll just need to remove some type hints in some places.


You'll need the following classes:

<?php

namespace App\Support\Websockets\Server\Channels;

use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager;

class LighthouseArrayChannelManager extends ArrayChannelManager
{
    protected function determineChannelClass(string $channelName): string
    {
        if (starts_with($channelName, 'private-lighthouse-')) {
            return PrivateLighthouseChannel::class;
        }

        return parent::determineChannelClass($channelName);
    }
}

And also:

<?php

namespace App\Support\Websockets\Server\Channels;

use Ratchet\ConnectionInterface;
use Nuwave\Lighthouse\Subscriptions\Contracts\StoresSubscriptions;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\PrivateChannel;

class PrivateLighthouseChannel extends PrivateChannel
{
    public function unsubscribe(ConnectionInterface $connection): void
    {
        parent::unsubscribe($connection);

        if (starts_with($this->channelName, 'private-lighthouse-') && !$this->hasConnections()) {
            static::lighthouseSubscriptionsStorage()->deleteSubscriber($this->channelName);
        }
    }

    private static function lighthouseSubscriptionsStorage(): StoresSubscriptions
    {
        return app(StoresSubscriptions::class);
    }
}

To "activate" this code you'll need to modify your config/websockets.php file and set the channel_manager option to App\Support\Websockets\Server\Channels\ArrayChannelManager::class.

When deployed you will need to restart you socket server to apply the new channel manager.

This will allow subscriptions to work with laravel-websockets using the pusher driver in lighthouse, you can leave out the webhook configuration since that is what the above code should provide (cleaning up subscribers and topics).


As a bonus I didn't like to have multiple auth endpoints for the websockets so I created a channel authorizer and disabled the Lighthouse pusher routes by removing this line from config/lighthouse.php:

            'pusher' => [
                'driver' => 'pusher',
-               'routes' => \Nuwave\Lighthouse\Subscriptions\SubscriptionRouter::class.'@pusher',
                'connection' => 'pusher',
            ],

I created this channel class:

<?php

namespace App\Broadcasting\Channels;

use App\User;
use Nuwave\Lighthouse\Subscriptions\Contracts\AuthorizesSubscriptions;

class LighthouseSubscriptionChannel
{
    private AuthorizesSubscriptions $subscriptionAuthorizer;

    public function __construct(AuthorizesSubscriptions $subscriptionAuthorizer)
    {
        $this->subscriptionAuthorizer = $subscriptionAuthorizer;
    }

    public function join(User $user): bool
    {
        return $this->subscriptionAuthorizer->authorize(request());
    }
}

And in my BroadcastServiceProvider added the following:

Broadcast::channel('lighthouse-{id}-{time}', \App\Broadcasting\Channels\LighthouseSubscriptionChannel::class);

This will "proxy" the authentication request for a private lighthouse channel through your "normal" broadcasting authentication endpoint so you can use the same Pusher config for both GraphQL subscriptions & normal Pusher channels which you might use in your application.


Possibly some for of this might make it into Ligthouse and/or spawn a package to supply this code, but for now this is working great for me and I have not found a downside to this approach except that I cannot use this if I use a seperate application for just the websockets server (that will require changes to the laravel-websockets package to achieve that).

If you'd like to chat, come find my on the Lighthouse Slack as @stayallive.

stayallive avatar Apr 23 '20 08:04 stayallive

@stayallive @GregPeden @thekonz I am open for including support for this in Lighthouse. At least a dozen people seem interested, judging by the likes on https://github.com/nuwave/lighthouse/issues/847#issuecomment-618254044. Is one of you willing to champion this issue? I think we would be able to come up with a robust solution that benefits everyone.

spawnia avatar Apr 21 '21 19:04 spawnia

I'll be having a child soon so I think I won't have the time in the coming weeks 😅

One wish I have for this feature is that leaving a subscription channel should result in the proper deletion of the subscription. That's one thing i know is a possible downside of the current subscription implementation. The list of subscribers on a topic just gets bigger and bigger if noone cleans up, so listening to the pusher channelVacated webhook is key currently. The echo implementation sadly does not use PresenceChannel anymore so we don't have a way to listen to redis and delete subscribers properly (another thing I don't have the time for currently ;)).

So maybe Alex or Greg?

thekonz avatar Apr 21 '21 20:04 thekonz

Congratulations 🎉

I came back to this issue because of https://github.com/nuwave/lighthouse/issues/1796. The collective debugging effort in there could be channeled towards a community driven solution.

spawnia avatar Apr 21 '21 20:04 spawnia

I beat you to it... had a kid 7 weeks ago. ;)

But I am currently using laravel-websockets myself. After I meet internal company deliverable goals (on which I am waaaay behind) I'll consider packaging it for inclusion here.

GregPeden avatar Apr 21 '21 23:04 GregPeden