Replace kiosk SignalR with polling — Azure App Service blocks anonymous hub handshakes

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 20:37:28 -04:00
parent 2acf54e1a9
commit 377bb1ce38
3 changed files with 50 additions and 42 deletions
@@ -68,7 +68,8 @@ public class KioskController : Controller
/// <summary> /// <summary>
/// Idle branded screen displayed on the front-desk tablet. /// Idle branded screen displayed on the front-desk tablet.
/// Validates the KioskDevice cookie; returns 403 if missing or token mismatch. /// 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.
/// </summary> /// </summary>
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Welcome() public async Task<IActionResult> Welcome()
@@ -86,6 +87,35 @@ public class KioskController : Controller
return View(); return View();
} }
/// <summary>
/// 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).
/// </summary>
[AllowAnonymous, HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> 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 });
}
/// <summary> /// <summary>
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the /// 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. /// KioskDevice cookie so no tenant context is needed on the anonymous request.
@@ -85,7 +85,6 @@
} }
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
</body> </body>
</html> </html>
@@ -1,10 +1,12 @@
"use strict"; "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 () { (function () {
const el = document.getElementById("kiosk-welcome-root"); const el = document.getElementById("kiosk-welcome-root");
if (!el) return; if (!el) return;
const companyId = el.dataset.companyId;
const dot = document.getElementById("kiosk-conn-dot"); const dot = document.getElementById("kiosk-conn-dot");
const label = document.getElementById("kiosk-conn-label"); const label = document.getElementById("kiosk-conn-label");
@@ -13,51 +15,28 @@
if (label) label.textContent = text; 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…"); setStatus("#94a3b8", "Connecting…");
// Skip WebSocket — anonymous WebSocket upgrades are blocked by the Azure App Service let active = true;
// 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();
connection.on("StartIntake", function (sessionToken) { async function poll() {
setStatus("#2563eb", "Starting…"); if (!active) return;
window.location.href = `/Kiosk/Intake/${sessionToken}/Contact`;
});
async function startConnection() {
try { 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"); setStatus("#16a34a", "Ready");
console.info("KioskHub connected, group kiosk-" + companyId); if (data.hasSession && data.sessionToken) {
} catch (err) { active = false;
setStatus("#ef4444", "Connection failed — retrying…"); setStatus("#2563eb", "Starting…");
console.warn("KioskHub connect failed, retrying in 10s…", err); window.location.href = `/Kiosk/Intake/${data.sessionToken}/Contact`;
setTimeout(startConnection, 10000); return;
} }
} catch {
setStatus("#f59e0b", "Connection issue — retrying…");
}
if (active) setTimeout(poll, 3000);
} }
startConnection(); setTimeout(poll, 500); // first poll quickly; subsequent every 3s
connection.onreconnecting(() => setStatus("#f59e0b", "Reconnecting…"));
connection.onreconnected(() => {
setStatus("#16a34a", "Ready");
console.info("KioskHub reconnected");
});
connection.onclose(() => {
setStatus("#ef4444", "Disconnected — retrying…");
setTimeout(startConnection, 10000);
});
})(); })();