Files
filament-short-url/src/Http/Controllers/ShortUrlLiveFeedStreamController.php

151 lines
4.5 KiB
PHP
Raw Normal View History

<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Bjanczak\FilamentShortUrl\Services\LiveFeedBroadcaster;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ShortUrlLiveFeedStreamController extends Controller
{
use AuthorizesRequests;
public function __invoke(Request $request, ShortUrl $shortUrl): StreamedResponse
{
$this->authorize('view', $shortUrl);
$cursor = (int) $request->query('cursor', 0);
$intervalSeconds = max(1, (int) config('filament-short-url.live_feed.sse_interval_seconds', 3));
$maxDurationSeconds = max(0, (int) config('filament-short-url.live_feed.sse_max_duration_seconds', 120));
return response()->stream(function () use ($shortUrl, $cursor, $intervalSeconds, $maxDurationSeconds): void {
if (LiveFeedBroadcaster::usesRedisPush()) {
$this->streamWithRedisPush($shortUrl, $cursor, $intervalSeconds, $maxDurationSeconds);
return;
}
$this->streamWithPolling($shortUrl, $cursor, $intervalSeconds, $maxDurationSeconds);
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache, no-transform',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
}
/**
* Redis pub/sub push: block on SUBSCRIBE between heartbeats (no sleep polling, no DB).
*/
private function streamWithRedisPush(
ShortUrl $shortUrl,
int $cursor,
int $intervalSeconds,
int $maxDurationSeconds,
): void {
$lastId = $cursor;
$deadline = time() + $maxDurationSeconds;
while (time() < $deadline && ! connection_aborted()) {
$latest = LiveFeedBroadcaster::latestId($shortUrl->id);
if ($latest > $lastId) {
$this->emitUpdate($latest);
$lastId = $latest;
}
$remaining = max(1, min($intervalSeconds, $deadline - time()));
$pushedId = LiveFeedBroadcaster::waitForPublish($shortUrl->id, $remaining);
if ($pushedId !== null && $pushedId > $lastId) {
$this->emitUpdate($pushedId);
$lastId = $pushedId;
continue;
}
$latest = LiveFeedBroadcaster::latestId($shortUrl->id);
if ($latest > $lastId) {
$this->emitUpdate($latest);
$lastId = $latest;
} else {
$this->emitHeartbeat();
}
}
}
/**
* Fallback when Redis is unavailable: poll cache cursor (and DB once when cache is cold).
*/
private function streamWithPolling(
ShortUrl $shortUrl,
int $cursor,
int $intervalSeconds,
int $maxDurationSeconds,
): void {
$lastId = $cursor;
$deadline = time() + $maxDurationSeconds;
while (time() < $deadline && ! connection_aborted()) {
$latest = $this->resolveLatestVisitId($shortUrl);
if ($latest > $lastId) {
$this->emitUpdate($latest);
$lastId = $latest;
} else {
$this->emitHeartbeat();
}
sleep($intervalSeconds);
}
}
private function resolveLatestVisitId(ShortUrl $shortUrl): int
{
$latest = LiveFeedBroadcaster::latestId($shortUrl->id);
if ($latest > 0) {
return $latest;
}
return (int) ShortUrlVisit::query()
->where('short_url_id', $shortUrl->id)
->where('is_bot', false)
->where('is_proxy', false)
->max('id');
}
private function emitUpdate(int $latestId): void
{
echo 'event: update'."\n";
echo 'data: '.json_encode(['latest_id' => $latestId], JSON_THROW_ON_ERROR)."\n\n";
$this->flushOutput();
}
private function emitHeartbeat(): void
{
echo ": heartbeat\n\n";
$this->flushOutput();
}
private function flushOutput(): void
{
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
}