nuwave / lighthouse

A framework for serving GraphQL from Laravel
https://lighthouse-php.com
MIT License
3.37k stars 438 forks source link

Support beyondcode/laravel-websockets as a subscription driver #847

Open nateajohnson opened 5 years ago

nateajohnson commented 5 years ago

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

olivernybroe commented 5 years ago

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

enzonotario commented 5 years ago

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.

gehad17 commented 5 years ago

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

enzonotario commented 5 years ago

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

lukadriel7 commented 5 years ago

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 ?

enzonotario commented 5 years ago

@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?

lukadriel7 commented 5 years ago

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

enzonotario commented 5 years ago

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.

lukadriel7 commented 5 years ago

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

enzonotario commented 5 years ago

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.

lukadriel7 commented 5 years ago

Thank you very much. I will check it

kdevan commented 4 years ago

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

joshhornby commented 4 years ago

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

lukadriel7 commented 4 years ago

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.

jozsefvamos commented 4 years ago

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

DonovanChan commented 4 years ago

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"}

jozsefvamos commented 4 years ago

@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}` : '',
                },
            },
        });
    }
DonovanChan commented 4 years ago

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                         |
jozsefvamos commented 4 years ago

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

DonovanChan commented 4 years ago

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

jozsefvamos commented 4 years ago

@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 :-)

stayallive commented 4 years ago

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.

spawnia commented 3 years ago

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

thekonz commented 3 years ago

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?

spawnia commented 3 years ago

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.

GregPeden commented 3 years ago

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.