Realtime KDS via Server-Sent Events
Deploy Ladill POS / deploy (push) Successful in 1m22s

Replace the kitchen display's 4s polling with a pushed feed. GET /kitchen/stream
holds an SSE connection and emits the board the moment it changes (~1s tick,
heartbeat between changes), so fired/bumped/guest orders land on screen near
instantly. Bounded to ~25s per connection so PHP-FPM workers recycle — the
browser's EventSource reconnects automatically; if EventSource is unavailable it
falls back to polling /kitchen/feed. Sends X-Accel-Buffering: no so nginx streams
it, and releases the session early (DB sessions don't lock, but be safe).

feed()/stream() now share buildTickets(); behaviour of the JSON feed (initial
load + fallback) is unchanged. Suite green (12).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-24 20:59:36 +00:00
co-authored by Claude Opus 4.8
parent d4f4821d96
commit 6c42f18186
4 changed files with 83 additions and 6 deletions
+63 -4
View File
@@ -9,7 +9,9 @@ use App\Models\PosSaleLine;
use App\Models\PosStation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class KitchenController extends Controller
{
@@ -24,11 +26,70 @@ class KitchenController extends Controller
/**
* Active kitchen tickets open sales with fired-but-not-served lines.
* Used for the initial load and as a fallback when SSE is unavailable.
*/
public function feed(Request $request): JsonResponse
{
$owner = $this->ownerRef($request);
return response()->json($this->payload($this->ownerRef($request)));
}
/**
* Realtime feed over Server-Sent Events. Pushes the board whenever it
* changes (~1s tick), with a heartbeat. Bounded lifetime so PHP-FPM workers
* recycle the browser's EventSource reconnects automatically.
*/
public function stream(Request $request): StreamedResponse
{
$owner = $this->ownerRef($request);
// Release the session lock early (DB driver doesn't lock, but be safe).
$request->session()->save();
$response = new StreamedResponse(function () use ($owner) {
while (ob_get_level() > 0) {
@ob_end_flush();
}
@set_time_limit(0);
$start = time();
$lastHash = null;
while (true) {
$payload = $this->payload($owner);
$json = json_encode($payload);
$hash = md5((string) $json);
if ($hash !== $lastHash) {
echo 'data: '.$json."\n\n";
$lastHash = $hash;
} else {
echo ": ping\n\n"; // heartbeat
}
@ob_flush();
@flush();
if (connection_aborted() || (time() - $start) >= 25) {
break;
}
sleep(1);
}
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('Connection', 'keep-alive');
$response->headers->set('X-Accel-Buffering', 'no'); // disable nginx buffering
return $response;
}
/** @return array{tickets: \Illuminate\Support\Collection, server_time: string} */
private function payload(string $owner): array
{
return ['tickets' => $this->buildTickets($owner), 'server_time' => now()->toIso8601String()];
}
private function buildTickets(string $owner): Collection
{
$sales = PosSale::owned($owner)
->openTickets()
->where('kitchen_status', PosSale::KITCHEN_ACTIVE)
@@ -36,7 +97,7 @@ class KitchenController extends Controller
->orderBy('kitchen_sent_at')
->get();
$tickets = $sales->map(function (PosSale $sale) {
return $sales->map(function (PosSale $sale) {
$lines = $sale->lines
->filter(fn (PosSaleLine $l) => in_array($l->kitchen_state, [
PosSaleLine::KITCHEN_QUEUED,
@@ -69,8 +130,6 @@ class KitchenController extends Controller
])->all(),
];
})->filter()->values();
return response()->json(['tickets' => $tickets, 'server_time' => now()->toIso8601String()]);
}
/**