javascript

Centrifugo JS client в Laravel: frontend и production

  • вторник, 19 мая 2026 г. в 00:00:05
https://habr.com/ru/articles/1036458/

Real-time система не заканчивается на том, что backend опубликовал событие в Centrifugo. Это только половина работы. Вторая половина начинается на фронтенде и в эксплуатации: подключение клиента, обработка входящих сообщений, переподключение, истечение токенов, потеря сети, восстановление состояния, Nginx, секреты, логи и мониторинг.

На локальной машине всё работает красиво: событие отправилось, WebSocket получил payload, интерфейс обновился. Потом проект попадает в production, пользователь открывает ноутбук после сна, сеть переключается с Wi-Fi на мобильную, токен истекает, Nginx режет соединение, Centrifugo пишет ошибку, frontend ничего не восстанавливает. И вот уже real-time превращается в интерфейс, который “иногда работает, сам оживает, иногда нет”.

В этой статье разберём, за что отвечает фронтенд в архитектуре Laravel + Centrifugo, как правильно работать с Centrifuge JS client и почему эксплуатация WebSocket-системы не второстепенная часть, а половина успеха.

В production real-time системе важно не только отправить событие, но и:

  • подключить frontend через Centrifuge JS client;

  • корректно обрабатывать входящие публикации;

  • переподключаться после обрыва сети;

  • обновлять connection token и subscription token;

  • не считать WebSocket единственным источником истины;

  • восстанавливать состояние через обычный HTTP-запрос;

  • правильно настроить Nginx;

  • не светить секреты и API key на фронтенде;

  • логировать ошибки подключения и публикации;

  • мониторить Centrifugo как отдельный инфраструктурный компонент.

Главная мысль:

WebSocket доставляет изменения, но состояние приложения всё равно должно уметь восстанавливаться через HTTP.

Где frontend находится в архитектуре Laravel + Centrifugo

В типовой схеме роли распределяются так:

Схема real-time архитектуры Laravel, Centrifugo и Frontend: Laravel генерирует токены и публикует события, Centrifugo управляет WebSocket-соединениями и каналами, Frontend получает события и восстанавливает состояние через HTTP.
Архитектура Laravel + Centrifugo + Frontend для real-time системы

Frontend не должен сам решать бизнес-логику. Он не должен проверять права доступа к чужим каналам. Он не должен знать секрет Centrifugo. Его задача проще и строже: подключиться, подписаться, принять событие, обновить UI и при проблемах восстановить состояние.

Подключение через Centrifuge JS client

На фронтенде обычно используется официальный JavaScript-клиент Centrifugo — centrifuge.

Пример базового подключения:

import { Centrifuge } from 'centrifuge';

const centrifuge = new Centrifuge('wss://example.com/connection/websocket', {
    token: connectionToken,
});

centrifuge.connect();

connectionToken frontend не генерирует сам. Он запрашивает его у Laravel:

async function fetchConnectionToken() {
    const response = await fetch('/api/realtime/connection-token', {
        credentials: 'include',
        headers: {
            Accept: 'application/json',
        },
    });

    if (!response.ok) {
        throw new Error('Failed to fetch Centrifugo connection token.');
    }

    const data = await response.json();

    return data.token;
}

Здесь важный момент: frontend получает token от backend, а не хранит секреты Centrifugo.

Секреты остаются на сервере. На клиент уходит только ограниченный JWT, который описывает конкретного пользователя и время жизни подключения.

Подписка на канал

После подключения клиент может подписаться на канал.

Например, на пользовательский канал:

const subscription = centrifuge.newSubscription(`user:${userId}`);

subscription.on('publication', function (context) {
    handleRealtimePublication(context.data);
});

subscription.on('subscribed', function () {
    console.log('Subscribed to user channel.');
});

subscription.on('error', function (context) {
    console.error('Subscription error.', context);
});

subscription.subscribe();

Если канал приватный, subscription token тоже должен выдаваться backend-ом.

Пример:

const subscription = centrifuge.newSubscription(`private:user:${userId}`, {
    token: subscriptionToken,
});

И снова: subscriptionToken выдаёт Laravel. Фронтенд не должен иметь возможности сам подписаться на чужой канал.

Обработка входящих публикаций

Самая распространённая ошибка — отправлять во frontend слишком большой payload.

Плохой payload:

{
    "id": 123,
    "user": {
        "id": 10,
        "name": "Max",
        "email": "max@example.com",
        "roles": ["admin"],
        "settings": {}
    },
    "order": {
        "id": 456,
        "items": [],
        "payments": [],
        "history": []
    }
}

Такой payload быстро превращает WebSocket в альтернативный REST API. Потом появляются проблемы с безопасностью, размером сообщений, версиями структуры и совместимостью фронтенда.

Хороший payload минимальный:

{
    "type": "order.status_changed",
    "id": 456,
    "status": "paid"
}

или:

{
    "type": "notification.created",
    "id": 987
}

Frontend получает событие и решает, что делать:

function handleRealtimePublication(payload) {
    switch (payload.type) {
        case 'order.status_changed':
            updateOrderStatus(payload.id, payload.status);
            return;

        case 'notification.created':
            incrementNotificationsCounter();
            return;

        default:
            console.warn('Unknown realtime event type.', payload);
    }
}

switch здесь нормален. Это диспетчеризация событий на фронтенде, а не бизнес-логика на 300 строк. Если событий станет много, можно заменить на map обработчиков.

const handlers = {
    'order.status_changed': handleOrderStatusChanged,
    'notification.created': handleNotificationCreated,
};

function handleRealtimePublication(payload) {
    const handler = handlers[payload.type];

    if (handler === undefined) {
        console.warn('Unknown realtime event type.', payload);

        return;
    }

    handler(payload);
}

Так код проще расширять.

WebSocket не должен быть единственным источником истины

Это принципиальный момент.

WebSocket-событие может не прийти:

  • пользователь потерял сеть;

  • браузер ушёл в sleep;

  • ноутбук проснулся через час;

  • соединение оборвалось;

  • токен истёк;

  • подписка не восстановилась;

  • сообщение пришло раньше, чем frontend загрузил нужный state.

Поэтому real-time событие лучше воспринимать как сигнал:

  • что-то изменилось;

  • обнови часть интерфейса;

  • при необходимости перезагрузи состояние через HTTP.

Например:

async function handleOrderStatusChanged(payload) {
    if (currentOrderId !== payload.id) {
        return;
    }

    await reloadOrder(payload.id);
}

async function reloadOrder(orderId) {
    const response = await fetch(`/api/orders/${orderId}`, {
        credentials: 'include',
        headers: {
            Accept: 'application/json',
        },
    });

    if (!response.ok) {
        throw new Error('Failed to reload order.');
    }

    const order = await response.json();

    renderOrder(order);
}

Иногда можно обновить UI прямо из payload. Например, счётчик уведомлений. Но для важных данных лучше восстановить состояние через HTTP.

Переподключение и потеря сети

Centrifuge JS client умеет переподключаться, но frontend всё равно должен учитывать состояния подключения.

Минимально нужно слушать события:

centrifuge.on('connecting', function (context) {
    console.log('Connecting to Centrifugo.', context);
    showRealtimeStatus('connecting');
});

centrifuge.on('connected', function (context) {
    console.log('Connected to Centrifugo.', context);
    showRealtimeStatus('connected');
});

centrifuge.on('disconnected', function (context) {
    console.warn('Disconnected from Centrifugo.', context);
    showRealtimeStatus('disconnected');
});

Для пользователя не всегда нужно показывать огромный красный баннер. Но для критичных интерфейсов статус соединения важен.

Например:

  • личный кабинет с платежами;

  • админка поддержки;

  • операторская панель;

  • stream overlay;

  • чат;

  • заказы;

  • финансовые статусы.

Если real-time отвалился, пользователь должен понимать, что данные могут обновляться с задержкой.

Истечение токенов

Connection token и subscription token обычно имеют ограниченное время жизни. Это правильно. Бессрочные токены — дорога к проблемам безопасности.

Но если токен истёк, клиент должен уметь получить новый.

Пример подключения с функцией получения токена:

const centrifuge = new Centrifuge('wss://example.com/connection/websocket', {
    getToken: async function () {
        return await fetchConnectionToken();
    },
});

Для приватных подписок аналогично:

const subscription = centrifuge.newSubscription(channelName, {
    getToken: async function () {
        return await fetchSubscriptionToken(channelName);
    },
});

Функция получения subscription token:

async function fetchSubscriptionToken(channelName) {
    const response = await fetch('/api/realtime/subscription-token', {
        method: 'POST',
        credentials: 'include',
        headers: {
            Accept: 'application/json',
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            channel: channelName,
        }),
    });

    if (!response.ok) {
        throw new Error('Failed to fetch Centrifugo subscription token.');
    }

    const data = await response.json();

    return data.token;
}

Laravel при этом обязан проверить, может ли текущий пользователь подписаться на этот канал. Не frontend. Не “мы же скрыли кнопку”. Именно backend.

Восстановление состояния после reconnect

После переподключения нельзя просто считать, что всё хорошо.

Пока соединение было оборвано, данные могли измениться. Поэтому после успешного reconnect часто нужно восстановить состояние:

centrifuge.on('connected', async function () {
    await reloadCurrentPageState();
});

Пример:

async function reloadCurrentPageState() {
    if (window.currentOrderId !== undefined) {
        await reloadOrder(window.currentOrderId);
    }

    await reloadNotificationsCounter();
}

Это особенно важно для интерфейсов, где состояние быстро меняется:

  • статусы платежей;

  • очередь обращений поддержки;

  • чат;

  • уведомления;

  • stream widgets;

  • заказы;

  • админские панели.

Смысл простой:

WebSocket ускоряет доставку изменений, но HTTP остаётся способом восстановить истину.

Nginx и WebSocket

На production WebSocket обычно проходит через Nginx. Если Nginx настроен неправильно, Centrifugo может быть исправен, Laravel может быть исправен, frontend может быть исправен, но соединение всё равно будет падать.

Минимальная настройка proxy для WebSocket:

location /connection/websocket {
    proxy_pass http://centrifugo:8000;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_read_timeout 3600;
    proxy_send_timeout 3600;
}

Критичные моменты:

proxy_http_version 1.1;
Upgrade;
Connection "upgrade";
достаточный proxy_read_timeout;
достаточный proxy_send_timeout.

Если timeout слишком короткий, соединения будут регулярно отваливаться.

Секреты и API key

Секреты Centrifugo нельзя отдавать на frontend.

На клиенте не должно быть:

CENTRIFUGO_TOKEN_HMAC_SECRET_KEY
CENTRIFUGO_API_KEY
CENTRIFUGO_ADMIN_PASSWORD

На frontend уходит только:

connection token;
subscription token;
публичный WebSocket endpoint.

Laravel хранит секреты в .env:

CENTRIFUGO_URL=http://centrifugo:8000
CENTRIFUGO_API_KEY=some-api-key
CENTRIFUGO_TOKEN_HMAC_SECRET_KEY=some-secret

Публикация в Centrifugo идёт с backend-а:

class CentrifugoPublisher
{
    public function publish(
        string $channel,
        array $payload,
    ): void {
        // HTTP API request to Centrifugo with API key.
    }
}

Frontend не должен публиковать системные события напрямую. Иначе пользователь сможет подделывать события, если найдёт способ вызвать publish.

Логи

В real-time системе нужны минимум три слоя логов.

Laravel

Логировать:

  • генерацию connection token;

  • отказ в subscription token;

  • публикацию события;

  • ошибки HTTP API Centrifugo;

  • повторные попытки queue job;

  • размер payload;

  • тип события;

  • канал публикации.

Пример контекста:

Log::channel('realtime')->info('Realtime event published.', [
    'event' => 'order.status_changed',
    'channel' => $channel,
    'orderId' => $orderId,
]);

Не логировать секреты и токены целиком.

Centrifugo

Смотреть:

  • подключения;

  • отключения;

  • ошибки авторизации;

  • ошибки подписок;

  • ошибки API publish;

  • нагрузку по каналам;

  • количество клиентов;

  • количество сообщений.

Frontend

Логировать хотя бы в консоль на старте, а лучше отправлять клиентские ошибки в систему мониторинга:

  • ошибка подключения;

  • ошибка подписки;

  • ошибка refresh token;

  • reconnect;

  • неизвестный тип события;

  • ошибка обработки payload.

Минимальный frontend logger:

function logRealtimeError(message, context = {}) {
    console.error(message, context);

    // Можно отправить в собственный endpoint:
    // fetch('/api/client-errors', ...)
}

Мониторинг

Centrifugo — это не “маленькая библиотека для WebSocket”. Это отдельный инфраструктурный компонент. Его надо мониторить.

Минимально отслеживать:

  • доступность Centrifugo;

  • количество активных соединений;

  • количество подписок;

  • количество публикаций;

  • ошибки publish API;

  • ошибки авторизации;

  • частоту reconnect;

  • потребление CPU/RAM;

  • размер и частоту payload;

  • время ответа Laravel endpoint для токенов.

Полезные вопросы:

  • Сколько клиентов сейчас подключено?

  • Есть ли всплеск disconnect?

  • Есть ли рост ошибок token refresh?

  • Сколько событий публикуется в минуту?

  • Не вырос ли payload до неприличных размеров?

  • Не стал ли endpoint выдачи токена узким местом?

Без мониторинга real-time система превращается в чёрный ящик: о проблемах узнаешь от пользователей. А нужно предвидеть проблемы заранее в мониторингах.

Типичные ошибки

1. Считать WebSocket источником истины

Событие может потеряться для конкретного клиента. Состояние нужно уметь восстановить через HTTP.

2. Отправлять слишком большой payload

WebSocket не должен заменять REST API. Отправляйте минимальные события.

3. Не обновлять token

Если токены истекают, но клиент не умеет получать новые, real-time будет периодически умирать.

4. Не обрабатывать reconnect

После reconnect нужно обновлять состояние, а не просто радоваться, что соединение вернулось.

5. Светить секреты Centrifugo на фронтенде

API key и secret должны жить только на backend-е.

6. Не логировать ошибки publish

Если Laravel не смог опубликовать событие, это должно быть видно. Иначе интерфейс молча не обновится.

7. Не мониторить Centrifugo

Centrifugo нужно воспринимать как production-сервис с логами.

Вывод

Фронтенд и эксплуатация в Laravel + Centrifugo — это половина real-time системы.

Backend может идеально публиковать события. Centrifugo может корректно доставлять сообщения. Но если frontend не умеет переподключаться, обновлять токены и восстанавливать состояние через HTTP, пользователь всё равно получит нестабильный интерфейс.

Надёжная real-time архитектура строится на простом принципе:

WebSocket нужен для быстрой доставки изменений, HTTP — для восстановления истины.