SSE и gRPC с Laravel
TrueAsyncServer внутри laravel-spawn устроен просто: принял HttpRequest, собрал из него Illuminate\Http\Request, прогнал через Kernel::handle(), получил Illuminate\Http\Response, буферизовал её целиком в HttpResponse и отправил. Один запрос — один ответ, всё содержимое сразу. Ровно то, к чему Laravel привык за всю свою историю.
Но во второй серии этого сайта был прогресс-бар на SSE и был gRPC на том же порту — оба живут не по формуле «один ответ», а по формуле «поток сообщений». Буферизованный Illuminate\Response для них не подходит по конструкции: у него нет ни sseEvent(), ни writeMessage(). Значит, контроллеру нужен способ дотянуться до настоящего, сырого HttpResponse в обход буфера.
Сырой ответ по первому требованию
laravel-spawn кладёт HttpRequest и HttpResponse текущего запроса в request_context() ещё до того, как передать управление Laravel-роутеру — тем же приёмом, которым сам Laravel кладёт туда auth и session из первой главы. Достать их можно двумя функциями:
trueasync_request(); // TrueAsync\HttpRequest
trueasync_response(); // TrueAsync\HttpResponseКак только контроллер написал в сырой ответ и закрыл его сам ($res->end()), обычный путь через Illuminate\Response больше не нужен: TrueAsyncServer перед буферизацией проверяет isClosed() и, если ответ уже отправлен вручную, ничего сверху не досылает. Контроллер по-прежнему обязан что-то вернуть — Laravel таков, — но содержимое этого возврата уже никого не интересует.
SSE: прогресс-бар внутри Laravel-маршрута
Раскрывать sseStart()/sseEvent()/sseComment() каждый раз через trueasync_response() неудобно, поэтому в пакете есть тонкая обёртка:
use Spawn\Laravel\Sse\Sse;
use function Async\delay;
Route::get('/import/progress', function () {
Sse::start(retryMs: 3000);
$import = currentImport();
while (!$import->isFinished()) {
Sse::event(
data: json_encode(['done' => $import->counter(), 'total' => $import->total()]),
event: 'progress',
);
if (!Sse::connected()) {
break; // вкладку закрыли
}
delay(1000);
}
Sse::event(event: 'finished', data: 'ok');
Sse::end();
return response()->noContent();
});Узнаёте код? Это дословно прогресс-бар из шестой главы серверной серии, только $res->sseEvent() заменился на Sse::event(). Внутри маршрута доступны и Auth::user(), и session(), и Eloquent — это обычный Laravel-обработчик, просто отвечает он не один раз, а потоком. Middleware, авторизация через Route::middleware('auth:sanctum'), current_context() для per-request состояния из первой главы — всё работает как обычно, потому что Kernel::handle() вокруг этого кода не изменился ни на строчку.
Одна деталь конфигурации не про Laravel, а про сам сервер: долгий стрим не должен упираться в таймаут записи, который у обычных ответов как раз и нужен, поэтому для сервисов со стримами его отключают глобально, ASYNC_WRITE_TIMEOUT=0 в .env, — тот же рычаг, что и в главе про продакшен серверной серии.
gRPC: контракт вместо маршрута
С gRPC компромисс жёстче. Протокол общается сообщениями в протобуф-кодировке, у которых нет осмысленного отображения ни в Illuminate\Http\Request, ни тем более в Response. Городить поддельный HTTP-запрос ради того, чтобы протащить его через роутинг и middleware, бессмысленно: gRPC-клиент не пришлёт ни cookie, ни CSRF-токен, ни тело формы. Поэтому gRPC в laravel-spawn идёт в обход Kernel целиком, отдельным путём, настроенным через конфиг:
// config/async.php
'grpc_handlers' => [
'/profile.ProfileService/GetProfile' => [
\App\Grpc\ProfileServiceHandler::class, 'getProfile',
],
],Сам обработчик резолвится через контейнер (значит, обычная DI-инъекция в конструкторе работает), а сигнатура метода — та же пара сырых объектов, что и в десятой главе серверной серии:
namespace App\Grpc;
use Profile\GetProfileRequest;
use Profile\Profile;
use TrueAsync\HttpRequest;
use TrueAsync\HttpResponse;
class ProfileServiceHandler
{
public function __construct(private readonly UserRepository $users) {}
public function getProfile(HttpRequest $req, HttpResponse $res): void
{
$getProfile = new GetProfileRequest();
$getProfile->mergeFromString($req->readMessage());
$user = $this->users->find($getProfile->getUserId());
$profile = (new Profile())
->setId($user->id)
->setName($user->name)
->setRegion($user->region);
$res->writeMessage($profile->serializeToString());
// чистый return: сервер сам допишет grpc-status: 0 (OK)
}
}$this->users пришёл через конструктор, как в любом Laravel-сервисе: контейнер и DI по-прежнему на месте, хотя маршрутизация Laravel для этого пути не участвовала вовсе. Ошибки передаются так же, как в чистом TrueAsync Server: трейлером grpc-status, а не HTTP-кодом.
public function getProfile(HttpRequest $req, HttpResponse $res): void
{
if (!$this->authorized($req)) {
$res->setTrailer('grpc-status', '7'); // PERMISSION_DENIED
return;
}
// ...
}Исключение, вылетевшее из обработчика, laravel-spawn сам превращает в grpc-status: 13 (INTERNAL) — то же поведение, которое было у голого сервера, просто теперь оно укрыто внутри адаптера.
Что из этого настоящее, а что нет
Оба пути — не эмуляция и не хак поверх Symfony StreamedResponse, а прямой доступ к тем же примитивам HttpRequest/HttpResponse, что и в серверной серии, прямо из-под Laravel-маршрута. Плата за это предсказуема: SSE-обработчик отвечает не через привычный return response()->json(...), а пишет сам и сам решает, когда закрыть соединение; gRPC-обработчик вообще не видит ни middleware, ни роутинг Laravel, только контейнер для DI. Здесь нет черной магии, но нет и притворства — если нужен настоящий поток, придётся выйти из уютного мира буферизованного Response туда, где живёт сам сервер.
Мы разобрали, что Laravel умеет: обычные запросы, стримы, gRPC. Осталось разобраться, чего Laravel не умеет сам по себе и что стоит особенно внимательно проверять в собственном и чужом коде, прежде чем доверить его конкурентным корутинам. Туда и пойдём в следующей главе.