Files
warmbly/realtime
Matthew Meszaros 695e2b5a33 fix: contacts crash, campaigns panic, websocket — 4 distinct bugs found while triaging the page-blank symptom
1) Contacts crash "c is null":
   contactRepository.Search declared `var contacts []models.Contact` so
   an empty result set returned a nil slice, which Go marshals as JSON
   null. The frontend's flatMap((p) => p.data) over null yields [null],
   and the page then accesses c.subscribed → throws. Initialize as
   make([]models.Contact, 0, limit+1) so the wire format is always [].
   Also defensive on the client: useSearchContacts + useCampaigns now
   coerce p.data ?? [] and drop nulls before returning.

2) Campaigns panic on any non-empty result:
   campaignRepository.Search allocated `make([]models.Campaign, 0, limit+1)`
   (length 0) then did `campaigns[i] = campaign`. That's an
   index-out-of-range on the first iteration. Switched to `append`.
   Anyone with at least one campaign would see a 500 / blank screen.

3) Websocket "Token expired":
   SocketTTL was 60s. The frontend reconnect backoff caps at 30s, so
   after a rejected handshake the next attempt could fire 30-60s
   later. Combined with rare back-pressure on /getaway the token was
   already past exp by the time the realtime saw it. Bumped to 10 min
   — short enough to keep the token low-impact, long enough to outlast
   the backoff schedule.

4) Websocket "Connection limit exceeded":
   Realtime.Connections only untracked on channel terminate, never on
   socket disconnect. Sockets that connected and disconnected without
   joining a channel leaked. Each reconnect loop bumped the counter
   until the per-user limit (10) was hit, after which every legitimate
   connect was rejected even after fixing #3.
   Fix: GenServer Process.monitor's the socket pid on track, and
   `:DOWN` handler calls do_untrack with the right (user_id, ip).

5) Phoenix protocol mismatch:
   Frontend appended vsn=2.0.0 to the WS URL, but sendRaw + joinChannel
   send the V1 object format. Realtime's Phoenix.Socket.V2.JSONSerializer
   crashed with a badmatch on the first phx_join, killing the socket
   right after connect. Switched to vsn=1.0.0 to match what the client
   actually emits.
2026-05-23 05:32:28 +00:00
..
2026-02-10 19:30:47 +01:00
2026-01-26 16:04:42 +01:00
2026-01-26 16:04:42 +01:00

Warmbly Realtime

WebSocket gateway for real-time events in Warmbly. Built with Phoenix Channels and Google Pub/Sub.

Architecture

┌─────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Go Backend │────>│  Google Pub/Sub │────>│ Elixir Realtime │
└─────────────┘     └─────────────────┘     └────────┬────────┘
                                                     │
                                                     │ WebSocket
                                                     v
                                            ┌─────────────────┐
                                            │  React Frontend │
                                            └─────────────────┘

Channels

  • user:{user_id} - User-specific events (emails, account status, bulk operations)
  • campaign:{campaign_id} - Campaign progress and status updates
  • account:{account_id} - Email account sync status and errors
  • bulk:{operation_id} - Bulk operation progress

Event Types

User Events

  • EMAIL_RECEIVED - New email in inbox
  • ACCOUNT_CONNECTED / ACCOUNT_DISCONNECTED / ACCOUNT_ERROR
  • BULK_STARTED / BULK_PROGRESS / BULK_COMPLETED

Campaign Events

  • CAMPAIGN_STARTED / CAMPAIGN_PAUSED / CAMPAIGN_COMPLETED
  • CAMPAIGN_PROGRESS - Emails sent, opens, clicks

Account Events

  • ACCOUNT_SYNCED - Sync completed
  • WARMUP_UPDATE - Warmup statistics

Setup

Prerequisites

  • Elixir 1.18+
  • Google Cloud project with Pub/Sub enabled

Environment Variables

# Required
JWT_SECRET=your_jwt_secret
SECRET_KEY_BASE=your_secret_key_base_min_64_chars
GCP_PROJECT_ID=your_gcp_project

# Optional
PORT=4000
PUBSUB_ENABLED=true
SENTRY_DSN=your_sentry_dsn
GOOGLE_APPLICATION_CREDENTIALS_JSON='{"type":"service_account",...}'

Development

# Install dependencies
mix deps.get

# Start server
mix phx.server

# Or in interactive mode
iex -S mix phx.server

Production

# Build release
MIX_ENV=prod mix release

# Run
_build/prod/rel/realtime/bin/realtime start

Client Connection

import { Socket } from "phoenix";

const socket = new Socket("wss://realtime.warmbly.com/socket", {
  params: { token: "jwt_token_from_api" }
});

socket.connect();

// Join user channel
const userChannel = socket.channel(`user:${userId}`, {});
userChannel.join()
  .receive("ok", () => console.log("Joined user channel"))
  .receive("error", (resp) => console.error("Unable to join", resp));

// Listen for events
userChannel.on("EMAIL_RECEIVED", (payload) => {
  console.log("New email:", payload);
});

// Join campaign channel
const campaignChannel = socket.channel(`campaign:${campaignId}`, {});
campaignChannel.join();
campaignChannel.on("CAMPAIGN_PROGRESS", (payload) => {
  console.log("Campaign progress:", payload);
});

Endpoints

  • GET /health - Health check
  • GET /stats - Connection statistics
  • WS /socket - WebSocket endpoint