From 377bb1ce38da41e9003f850ba81ffc5d0ee0a133 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 13 May 2026 20:37:28 -0400 Subject: [PATCH] =?UTF-8?q?Replace=20kiosk=20SignalR=20with=20polling=20?= =?UTF-8?q?=E2=80=94=20Azure=20App=20Service=20blocks=20anonymous=20hub=20?= =?UTF-8?q?handshakes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SignalR WebSocket and SSE both receive immediate 'Handshake was canceled' from the server-side hub context. The 15-second delay between negotiate and SSE connect reveals the handshake timer has expired before the transport opens — caused by Azure App Service's ingress proxy resetting anonymous long-lived connections. Replacement: /Kiosk/PollSession (anonymous GET, no-cache) queried every 3 seconds. Returns the most recent Active InPerson session created in the last 60 seconds. The kiosk navigates when hasSession=true. Status dot: gray->green on first success, yellow on network error, blue when navigating. Removed signalr.min.js from kiosk layout. Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/KioskController.cs | 32 +++++++++- .../Views/Shared/_KioskLayout.cshtml | 1 - .../wwwroot/js/kiosk-welcome.js | 59 ++++++------------- 3 files changed, 50 insertions(+), 42 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/KioskController.cs b/src/PowderCoating.Web/Controllers/KioskController.cs index fa6686d..31689f9 100644 --- a/src/PowderCoating.Web/Controllers/KioskController.cs +++ b/src/PowderCoating.Web/Controllers/KioskController.cs @@ -68,7 +68,8 @@ public class KioskController : Controller /// /// Idle branded screen displayed on the front-desk tablet. /// Validates the KioskDevice cookie; returns 403 if missing or token mismatch. - /// The view connects to KioskHub and listens for StartIntake events. + /// The view polls /Kiosk/PollSession every 3 seconds and navigates when staff + /// triggers a session via the Dashboard "Start Intake" button. /// [AllowAnonymous] public async Task Welcome() @@ -86,6 +87,35 @@ public class KioskController : Controller return View(); } + /// + /// Lightweight polling endpoint called every 3 seconds by the kiosk Welcome screen. + /// Returns the most recent InPerson KioskSession created in the last 60 seconds so + /// the tablet can navigate without relying on SignalR (which Azure App Service blocks + /// for anonymous WebSocket/SSE connections through its ingress proxy). + /// + [AllowAnonymous, HttpGet] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task PollSession() + { + var cookie = ReadKioskCookie(); + if (cookie == null) return Json(new { hasSession = false }); + + var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true); + if (company == null || company.KioskActivationToken != cookie.Value.token) + return Json(new { hasSession = false }); + + var window = DateTime.UtcNow.AddSeconds(-60); + var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync( + s => s.CompanyId == cookie.Value.companyId + && s.SessionType == KioskSessionType.InPerson + && s.Status == KioskSessionStatus.Active + && s.CreatedAt >= window, + ignoreQueryFilters: true); + + if (session == null) return Json(new { hasSession = false }); + return Json(new { hasSession = true, sessionToken = session.SessionToken }); + } + /// /// Serves the company logo for anonymous kiosk pages. Resolves the company from the /// KioskDevice cookie so no tenant context is needed on the anonymous request. diff --git a/src/PowderCoating.Web/Views/Shared/_KioskLayout.cshtml b/src/PowderCoating.Web/Views/Shared/_KioskLayout.cshtml index a6e6598..729ba05 100644 --- a/src/PowderCoating.Web/Views/Shared/_KioskLayout.cshtml +++ b/src/PowderCoating.Web/Views/Shared/_KioskLayout.cshtml @@ -85,7 +85,6 @@ } - @await RenderSectionAsync("Scripts", required: false) diff --git a/src/PowderCoating.Web/wwwroot/js/kiosk-welcome.js b/src/PowderCoating.Web/wwwroot/js/kiosk-welcome.js index e2a2cee..05fecf1 100644 --- a/src/PowderCoating.Web/wwwroot/js/kiosk-welcome.js +++ b/src/PowderCoating.Web/wwwroot/js/kiosk-welcome.js @@ -1,10 +1,12 @@ "use strict"; +// Polls /Kiosk/PollSession every 3 seconds and navigates when staff triggers an intake. +// SignalR was replaced with polling because Azure App Service's ingress proxy cancels +// anonymous WebSocket and SSE handshakes before the SignalR protocol exchange completes. (function () { const el = document.getElementById("kiosk-welcome-root"); if (!el) return; - const companyId = el.dataset.companyId; const dot = document.getElementById("kiosk-conn-dot"); const label = document.getElementById("kiosk-conn-label"); @@ -13,51 +15,28 @@ if (label) label.textContent = text; } - if (!companyId) { - setStatus("#ef4444", "Not configured (no company ID)"); - console.error("KioskHub: data-company-id is empty — kiosk activation may be invalid."); - return; - } - setStatus("#94a3b8", "Connecting…"); - // Skip WebSocket — anonymous WebSocket upgrades are blocked by the Azure App Service - // ingress proxy before the SignalR handshake completes. Server-Sent Events and - // long polling work fine for the low-frequency "StartIntake" push this hub needs. - const connection = new signalR.HubConnectionBuilder() - .withUrl(`/hubs/kiosk?companyId=${companyId}`, { - transport: signalR.HttpTransportType.ServerSentEvents | signalR.HttpTransportType.LongPolling - }) - .withAutomaticReconnect([2000, 5000, 10000, 30000]) - .configureLogging(signalR.LogLevel.Information) - .build(); + let active = true; - connection.on("StartIntake", function (sessionToken) { - setStatus("#2563eb", "Starting…"); - window.location.href = `/Kiosk/Intake/${sessionToken}/Contact`; - }); - - async function startConnection() { + async function poll() { + if (!active) return; try { - await connection.start(); + const res = await fetch("/Kiosk/PollSession", { cache: "no-store" }); + if (!res.ok) throw new Error("HTTP " + res.status); + const data = await res.json(); setStatus("#16a34a", "Ready"); - console.info("KioskHub connected, group kiosk-" + companyId); - } catch (err) { - setStatus("#ef4444", "Connection failed — retrying…"); - console.warn("KioskHub connect failed, retrying in 10s…", err); - setTimeout(startConnection, 10000); + if (data.hasSession && data.sessionToken) { + active = false; + setStatus("#2563eb", "Starting…"); + window.location.href = `/Kiosk/Intake/${data.sessionToken}/Contact`; + return; + } + } catch { + setStatus("#f59e0b", "Connection issue — retrying…"); } + if (active) setTimeout(poll, 3000); } - startConnection(); - - connection.onreconnecting(() => setStatus("#f59e0b", "Reconnecting…")); - connection.onreconnected(() => { - setStatus("#16a34a", "Ready"); - console.info("KioskHub reconnected"); - }); - connection.onclose(() => { - setStatus("#ef4444", "Disconnected — retrying…"); - setTimeout(startConnection, 10000); - }); + setTimeout(poll, 500); // first poll quickly; subsequent every 3s })();