laravel / echo

Laravel Echo library for beautiful Pusher and Ably integration.
https://laravel.com/docs/broadcasting#client-side-installation
MIT License
1.17k stars 179 forks source link

Laravel Echo is not subscribing to private channel #360

Closed NK0D1NG closed 1 year ago

NK0D1NG commented 1 year ago

Description:

Laravel Echo is not subscribing to a private channel. After instantiating and authenticating Laravel Echo the EventListener ('listen'-Method) never gets called. In the websocket connection is no subscription message and Soketi does not receive any subscription message. Using a public channel works as expected.

Steps To Reproduce:

  1. Setup Laravel Broadcasting with Soketi according to the docs
  2. Generate Event, Listener and a Private Channel
  3. Instantiate Laravel Echo
  4. Listen to an event on that channel
  5. Watch Browser Network Tab

Frontend Code for 3.:

import Echo from 'laravel-echo';

const echo = ref()
const channelName = 'c89e8e0f-d86e-4b5b-887c-3ba24fcc4637'

function setupEcho() {
    const token = useCookie('XSRF-TOKEN').value
    const config = useRuntimeConfig()
    if (!echo.value) {
        echo.value = new Echo({
        authorizer: (channel, options) => {
        return {
            authorize: (socketId, callback) => {
                const response = $fetch(config.baseURL + '/broadcasting/auth', {
                    async onRequest({ request, options }) {
                    options.method = 'post'
                    options.body = {
                        socket_id: socketId,
                        channel_name: channel.name
                    }
                    options.headers = {'X-XSRF-TOKEN': token,
                    'Accept': 'application/json',
                    },
                    options.credentials = 'include'
                    },
                    async onResponseError({ request, response, options }) {
                        console.log(response + response.status)
                    },
                })
                console.log(response)
            }
        };
        },
        broadcaster: 'pusher',
        key: 'app-key',
        cluster: 'mt1',
        wsHost: 'localhost',
        wsPort: 6001,
        forceTLS: false,
        disableStats: true,
        auth: {
            headers: {'X-XSRF-TOKEN': token,
                       'Accept': 'application/json',
            },
        },
        enabledTransports: ['ws', 'wss'],
    });
    }
}

Frontend Code for 4.:

function joinRoomChat() {
    console.log('Joining room: ' + channelName)
    echo.value.private(channelName)
    .listen('.client.stream.started', (e) => {
        console.log('I never get called!')
        console.log(e)
    });
}

Examples for 5.: browser requests: grafik.png

POST-data sent within the request to broadcasting/auth:

{"socket_id":"2329902672.3158557083","channel_name":"private-room.c89e8e0f-d86e-4b5b-887c-3ba24fcc4637"}

The response from the auth-Request:

{"auth":"app-key:ad67d31af255737d954c8e1bf67b7db9e3e44494bd25b7f307d9f270ea7f2b3d"}

This is the request/response-payload of the first (and only) websocket connection Laravel Echo creates: grafik.png

The actual callback function inside the listen-handler never gets called from Laravel Echo. There is just nothing in the browser console. It seems like Laravel Echo does not even send a subscribe message when using private channels.

If using the same event with a public channel Laravel Echo actually subscribes and I get the event data: grafik

I just made a public 'test' channel on the backend and changed the event to broadcast on that channel. This is how I changed the Frontend Code:

function joinRoomChat() {
    echo.value.channel('test')
    .listen('.client.stream.started', (e) => {
        console.log('I never get called!')
        console.log(e)
    });
}

I am not sure if there is a bug in Laravel Echo or maybe it is related to Soketi? Nevertheless I had the same problems using the laravel-websocket package, too so I think this bug is Laravel Echo related.

Repository to demonstrate the issue:

The frontend built with NuxtJS:

https://github.com/NK0D1NG/laravel-broadcasting-fe

The backend build with Laravel:

https://github.com/NK0D1NG/laravel-broadcasting-be

Related topics

https://laracasts.com/discuss/channels/laravel/laravel-echo-not-listening-to-events-using-it-with-laravel-homestead-could-it-be-a-compatibility-issue-with-vue-3

I also posted a lot of information about the problem in this post on Laracast (including more Laravel-specific backend code): https://laracasts.com/discuss/channels/laravel/event-listener-never-gets-called-even-if-the-event-gets-fired?page=1&replyId=849115

jessarcher commented 1 year ago

Hi @NK0D1NG, I am unable to replicate this issue.

Your example code seemed quite complicated so I just set up a minimal example to confirm that I can subscribe to private channels.

I spun up a Soketi server with Docker, per their docs:

docker run -p 6001:6001 -p 9601:9601 quay.io/soketi/soketi:1.4-16-debian

I set up a fresh Laravel install with Laravel Breeze's Vue stack to scaffold out auth views.

I installed the Pusher and Echo packages:

composer require pusher/pusher-php-server
npm install --save-dev laravel-echo pusher-js

I configured my .env as follows:

BROADCAST_DRIVER=pusher
PUSHER_APP_ID=app-id
PUSHER_APP_KEY=app-key
PUSHER_APP_SECRET=app-secret
PUSHER_HOST=127.0.0.1
PUSHER_PORT=6001
PUSHER_SCHEME=http
PUSHER_APP_CLUSTER=mt1

I uncommented the default Echo configuration in resources/js/bootstrap.js:

import Echo from 'laravel-echo';

import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
    wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
    wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

I then logged into the Laravel app and observed Echo connecting to Soketi:

image

I then uncommented App\Providers\BroadcastServiceProvider::class in config.app and set up a channel in routes/channels.php:

Broadcast::channel('foo-channel', fn () => true);

I then created an event with artisan make:event FooEvent and updated it to implement ShouldBroadcast. I updated the constructor to accept a $message and updated the broadcastOn method to use the foo channel:

class FooEvent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public string $message)
    {
        //
    }

    public function broadcastOn()
    {
        return new PrivateChannel('foo-channel');
    }
}

In Dashboard.vue I subscribed to the event on the private channel:

Echo.private('foo-channel')
    .listen('FooEvent', (e) => {
        console.log(e);
    });

And observed that Echo made the auth request:

image

I then used Laravel Tinker to dispatch the event:

image

And saw it come through in the console:

image

Are you able to confirm whether the issue exists for you with a minimal setup like this? If not, is there something specific I should do to replicate the issue?

NK0D1NG commented 1 year ago

Hey @jessarcher thanks for investigating into this. The only difference to my setup could be the custom auth guard I am using:

from config/auth.php:

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'vcs' => [
            'driver' => 'session',
            'provider' => 'clients',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],

        'clients' => [
            'driver' => 'eloquent',
            'model' => App\Models\Client::class,
        ],
    ],

from routes/channels.php:

Broadcast::channel('room.{ruuid}', function ($client, $ruuid) {
    Log::info('Broadcasting.Channel: ' . $ruuid);
    return $client->room_uuid == $ruuid;
}, ['guards' => ['vcs']]);

My app/Providers/BroadcastServiceProvider.php:

<?php

namespace App\Providers;

use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;

class BroadcastServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        Broadcast::routes(['middleware' => 'web', 'guards' => 'vcs']);

        require base_path('routes/channels.php');
    }
}

This is also the reason why my Echo setup seems complicated. I am also using Laravel Sanctum with an external Vue SPA which authenticates using Laravel's default session driver as you can see. Everything is set up like described in the official docs and not the first time I am doing it like this. Because Laravel Sanctum requires the X-XSRF-TOKEN and the Session Cookie to be set and Laravel Echo does not send token/session cookie automatically I added it by myself using the custom authorizer. As well as the application/json header. The Vue SPA is a different repository/project. Authenticating using the custom guard works just fine (for normal API routes and for the channels as well as you can see in the images in my original post). Because I have no monorepo I wasn't able to setup a minimal example for this whole setup yet.

So maybe Echo has a problem with the custom guard?

Can you confirm a subscription message inside the websocket connection? What do your request/responses in the websocket connection look like? Is there a subscription/subscription succeeded message? Can you confirm it works in your minimal example using a custom guard and Laravel Sanctum? I am using just another Eloquent model like:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
use App\Traits\Uuids;

class Client extends Authenticatable
{
    use HasFactory, HasApiTokens, HasRoles, Uuids;

    protected $guard_name = "vcs";

    public function room()
    {
        return $this->belongsTo(Room::class, 'room_uuid', 'uuid');
    }
}

I also use Redis as my queue driver.

I can also confirm that the event is correctly fired (the Laravel Event Listener gets called and I can see the event payload in the Soketi Logs) - so the only thing missing is the subscription call for the private channel from Laravel Echo..

NK0D1NG commented 1 year ago

By the way: I am not using axios - I am using the fetch API (The frontend is developed with NuxtJS which uses ohmyfetch as a fetch API dependency).

NK0D1NG commented 1 year ago

@driesvints @jessarcher I provided two demo repositories (links in the original post) which also demonstrate the issue. They should work out of the box after installing the dependencies. Please follow the README of the Laravel Backend Repo to do the setup and create a first user with a custom guard. Even with this simplified setup Laravel Echo does not subscribe to the private channel.

For the frontend just use the newly created user (model is called 'client') to login. Then click on the Join Room Chat button which calls the listen-method - but the callback never gets executed and there is no subscription message in the websocket connection.

NK0D1NG commented 1 year ago

I was finally able to reproduce the issue and solve it. It is important to call the callback on the custom authorizer:

function setupEcho() {
    const token = useCookie('XSRF-TOKEN').value
    const config = useRuntimeConfig()
    channelName.value = 'room.' + roomsStore.currentRoom.roomUuid
    console.log('Channel Name: ' + channelName)
    if (!echo.value) {
        echo.value = new Echo({
        authorizer: (channel, options) => {
        return {
            authorize: (socketId, callback) => {
                const request = $fetch(config.baseURL + '/broadcasting/auth', {
                    async onRequest({ request, options }) {
                    options.method = 'post'
                    options.body = {
                        socket_id: socketId,
                        channel_name: channel.name
                    }
                    options.headers = {'X-XSRF-TOKEN': token,
                    'Accept': 'application/json',
                    },
                    options.credentials = 'include'
                    },
                })
                request.then((response) => {
                    console.log(response)
                    callback(null, response)
                })
            }
        };
        },
        broadcaster: 'pusher',
        key: 'app-key',
        cluster: 'mt1',
        wsHost: 'localhost',
        wsPort: 6001,
        forceTLS: false,
        disableStats: true,
        encrypted: true,
        auth: {
            headers: {'X-XSRF-TOKEN': token,
                       'Accept': 'application/json',
            },
        },
        enabledTransports: ['ws', 'wss'],
    });
    }
}

This part is the important one:

                request.then((response) => {
                    console.log(response)
                    callback(null, response)
                })

I am not sure what callback is used internally by Laravel Echo but it is obligatory for the subscription of private/presence channels to work. All examples in the docs use axios so I had to change some code to work with the fetch API (which is used by the ohmyfetch library). After calling the callback (with a null value as first parameter which seems a bit strange but is also mentioned in the Laravel Docs) the subscription works and I get the event data:

grafik

This issue was not easy to debug, because the same setup works perfectly fine with public channels (including the subscription!) and there are absolutely no errors in the console when trying the same with private channels.

The issue can be closed.

Stetzon commented 1 month ago

Laravel 11: For those using a simple Sanctum/Echo/Reverb setup, you can try the following:

// config/cors.php

'paths' => ['api/*', 'sanctum/csrf-cookie', 
    '/broadcasting/auth' // add me
],
// routes/channels.php

use Illuminate\Support\Facades\Broadcast;

Broadcast::routes(['middleware' => ['api', 'auth:sanctum']]);
// main.js (of your SPA)

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
import axios from 'axios';
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
    authorizer: (channel, options) => ({
        authorize: (socketId, callback) => {
            axios.post(`${import.meta.env.VITE_API_URL}/broadcasting/auth`, {
                socket_id: socketId,
                channel_name: channel.name,
            }, {
                withCredentials: true, // this is
                withXSRFToken: true, // important part
            })
                .then(response => {
                    callback(null, response.data);
                })
                .catch(error => {
                    callback(error);
                });
        },
    }),
});

The default csrf configuration for Echo does not work out of the box with sanctum, as it tries to find the csrf token on the window object or in the DOM. We want to configure axios to be able to grab it from the cookie like it does on every other API request.

Since the /broadcasting/auth route is now automatically published, the Broadcast::routes() helper was removed from the docs. Curiously, the custom authorizer docs were removed as well. See change here.

This SO post helped me get here.