Task #632: realtime order delivery for recipients on iPad PWA

Bug: order creator (services manager) saw new orders instantly with
sound, but other users holding orders.receive — even with the app
foregrounded on the Orders screen — got nothing until they force-quit
and reopened the PWA, at which point the orders appeared. Root cause:
Socket.IO silently drops behind Tailscale/NAT on iPad PWA and the
client neither detected it nor refetched missed state on reconnect.
The creator was unaffected because their POST mutation updates their
queries locally without depending on the realtime channel.

Client (artifacts/tx-os/src/hooks/use-notifications-socket.ts):
- Tighten io() options: reconnection {Delay 500, DelayMax 3000,
  Attempts Infinity, timeout 10s}.
- Track `wasDisconnected` flag; on `disconnect` flip it and log the
  reason (skipping the clean "io client disconnect" path).
- On `connect`, if previously disconnected, invalidate every realtime-
  driven query key: notifications, home stats, my/incoming orders,
  notes, note-folders, exec meetings (list/alert-state/notifications),
  apps, /me, roles, permissions. Warmup window is reset BEFORE the
  refetch so any flushed notification_created events post-reconnect
  don't chime.
- Add a `visibilitychange` listener: on foreground return, if the
  socket is disconnected, force `socket.connect()`; if it's "connected"
  but possibly half-open (silent drop), round-trip a 3s-timeout
  `client_health_probe` ack — on timeout, `disconnect().connect()`.
- `connect_error` logger for field debugging.

Server (artifacts/api-server/src/index.ts):
- Tighten Socket.IO pingInterval=10s, pingTimeout=5s (was default
  25s/20s) so dead-connection detection cycle drops from ~45s to ~15s.
- Add `client_health_probe` handler that acks immediately — pairs
  with the client-side half-open probe.

Deviations from plan: skipped the optional UI connection indicator
(point 5) — not necessary to fix the reported bug; can ship later if
users still feel uncertain about connection state.

Architect approved with one minor caveat: severe (>3s) transient
latency on foreground could trigger a one-off socket cycle. Acceptable
tradeoff and explicitly documented in the comment.

Pre-existing TS errors in api-server/src/routes/push.ts are unrelated
to this task and not touched by this diff.
This commit is contained in:
riyadhafraa
2026-05-24 10:29:04 +00:00
parent a68c95436d
commit be770081f8
2 changed files with 117 additions and 0 deletions
+18
View File
@@ -23,6 +23,15 @@ const httpServer = createServer(app);
export const io = new SocketIOServer(httpServer, {
path: "/api/socket.io",
// #632: Detect dead connections faster. Defaults are 25s/20s which
// means a silently-dropped WebSocket (Tailscale NAT timeout, iPad
// PWA suspension, proxy hiccup) takes up to ~45s before the client
// notices and reconnects — long enough that recipients miss the
// realtime order push entirely. Tightening to 10s/5s puts the
// detection cycle at ~15s, well inside the "is this app even
// working?" window users complain about.
pingInterval: 10000,
pingTimeout: 5000,
cors: {
origin: process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(",").map((o) => o.trim())
@@ -54,6 +63,15 @@ io.on("connection", (socket) => {
socket.join(`user:${userId}`);
// #632: Client foreground-recovery probe. The web client emits this
// on `visibilitychange` to verify the WebSocket isn't half-open
// (silently dead behind Tailscale/NAT) — we ack immediately so a
// genuinely-alive connection completes the round-trip, and a dead
// one times out on the client side and force-cycles the transport.
socket.on("client_health_probe", (ack?: () => void) => {
if (typeof ack === "function") ack();
});
socket.on("disconnect", () => {
logger.info({ socketId: socket.id }, "Socket disconnected");
});