From 6c42f181868aa648b5bd9e67e6918809a15b17a7 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 24 Jun 2026 20:59:36 +0000 Subject: [PATCH] Realtime KDS via Server-Sent Events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controllers/Pos/KitchenController.php | 67 +++++++++++++++++-- resources/js/app.js | 20 +++++- resources/views/pos/kitchen/index.blade.php | 1 + routes/web.php | 1 + 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Pos/KitchenController.php b/app/Http/Controllers/Pos/KitchenController.php index d500648..8440837 100644 --- a/app/Http/Controllers/Pos/KitchenController.php +++ b/app/Http/Controllers/Pos/KitchenController.php @@ -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()]); } /** diff --git a/resources/js/app.js b/resources/js/app.js index 5726cd9..9dc3394 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -444,17 +444,33 @@ Alpine.data('posTicket', (config = {}) => ({ Alpine.data('posKitchen', (config = {}) => ({ tickets: [], feedUrl: config.feedUrl || '/kitchen/feed', + streamUrl: config.streamUrl || '', bumpUrl: config.bumpUrl || '/kitchen/lines/__ID__/bump', csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '', now: Date.now(), loaded: false, activeStation: '', + es: null, init() { - this.load(); - setInterval(() => this.load(), 4000); + this.load(); // fast initial paint + this.connect(); // realtime stream (falls back to polling) setInterval(() => { this.now = Date.now(); }, 1000); }, + connect() { + if (! this.streamUrl || typeof window.EventSource === 'undefined') { + setInterval(() => this.load(), 4000); // fallback polling + return; + } + this.es = new EventSource(this.streamUrl); // auto-reconnects on close/error + this.es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + this.tickets = data.tickets || []; + this.loaded = true; + } catch (x) { /* ignore malformed frame */ } + }; + }, get visibleTickets() { if (! this.activeStation) return this.tickets; const sid = String(this.activeStation); diff --git a/resources/views/pos/kitchen/index.blade.php b/resources/views/pos/kitchen/index.blade.php index 074aebf..432e6c9 100644 --- a/resources/views/pos/kitchen/index.blade.php +++ b/resources/views/pos/kitchen/index.blade.php @@ -1,6 +1,7 @@
diff --git a/routes/web.php b/routes/web.php index efab49f..3263aa5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -67,6 +67,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/kitchen', [KitchenController::class, 'index'])->name('pos.kitchen'); Route::get('/kitchen/feed', [KitchenController::class, 'feed'])->name('pos.kitchen.feed'); + Route::get('/kitchen/stream', [KitchenController::class, 'stream'])->name('pos.kitchen.stream'); Route::post('/kitchen/lines/{line}/bump', [KitchenController::class, 'bump'])->name('pos.kitchen.bump'); // Menu depth — categories, kitchen stations, and modifier groups/options.