Architecture
EzFD is a Next.js 16 App Router application (standalone output, run as
node server.js) on PostgreSQL 16, with Tailwind v4 for styling. Dark is the
default theme, so light-mode styles carry the light: prefix.
There are five ideas worth understanding; the rest is ordinary CRUD.
Real-time updates
Every operator needs to see every QSO immediately, including for duplicate checking. Rather than polling, the app uses PostgreSQL's own notification system.
QSO INSERT ─→ trigger ─→ pg_notify('qsos_<event_id>')
│
┌─────────┴──────────┐
│ one shared │ a single pg connection for the
│ listener │ whole process, LISTENing for
│ (lib/realtime.ts) │ every event that has a stream open
└─────────┬──────────┘
│ fanned out in memory
┌─────────┴──────────┐
│ /api/realtime │ one Server-Sent Events stream
│ │ per browser window
└─────────┬──────────┘
│
┌─────────┴──────────┐
│ every browser │
└────────────────────┘
Three channels per event: qsos_<id> for QSO changes, ses_<id> for
checkouts and claims, and presence_<id> for a logging window changing band
or mode, arriving or going QRT. One SSE endpoint serves all three, tagging each
message with an event name (qso, reservation, presence) so the client can
tell them apart. That keeps it to one stream per browser window rather than one
per data type.
The database side is shared. Each stream used to open its own PostgreSQL
connection to LISTEN, so a hundred open tabs — or a hundred requests from
anybody, for event ids that did not even exist — took every connection the
server had, and every other API call failed until they closed. Now one
connection listens on behalf of every stream: an event's channels are
subscribed when its first stream opens and released when its last one closes.
That relies on EzFD being a single node server.js. The route refuses an id
that is not a UUID, or that names no event, before anything reaches the
database; channel names are quoted with escapeIdentifier. Total streams are
capped by EZFD_MAX_STREAMS (see Configuration).
NOTIFY refuses a payload of 8,000 bytes or more, and refuses it by raising
inside the trigger, which rolled back the write being announced. The triggers
therefore send a row too large to fit as its id alone, and the shared listener
fetches the row before passing it on, so the browser receives the same shape
either way. The API also trims free text well below that size.
Three screens open a stream: the logging window, the CW keying popout — a separate document — and the operating position picker, which is watching for a checkout made somewhere else while an operator decides where to sit. The dashboard opens a fourth.
Presence is announced only when something an operator would see changes. Every logging window heartbeats every 30 seconds to say it is still there, and those heartbeats are deliberately silent: announcing them would turn each one into a refetch by every other window. A window that closes without going QRT (a lid shut, a battery gone) announces nothing, so screens still sweep presence and claims once a minute. The sweep is the recovery path too, since an SSE stream can die quietly, and it pauses while the page is hidden. The heartbeat never pauses, because a logger in a background tab is still on the air.
Consequences:
- Anything deciding whether a claim is live from a clock has to read that clock at least as often as it expects data. The position picker read it on the poll interval, which was invisible until checkouts started arriving on the stream — a claim made now begins after the last tick, so fresh data landed and the board still called it not yet started.
- A connection pooler in transaction mode breaks this silently. See Configuration.
- nginx must not buffer the SSE response. The app sets
X-Accel-Buffering: no; the deployed nginx config disables buffering too. - The stream sends a keepalive comment every 25 seconds so proxies don't time it out.
- A stream never finishes on its own, so it has to be ended on shutdown. Next
closes the HTTP server on
SIGTERMand waits for every connection to finish, and one open tab used to hold a restart until systemd force-killed the process at 90 seconds. The route keeps a set of its open streams and ends them all onSIGTERMand closes the shared listener, so the process exits in well under a second. Each browser sees its stream drop and reconnects once the new process is up, which is also the signal that drains its offline queue.
Offline tolerance
Field Day sites have unreliable networking, so the log is written locally first and synced second.
operator logs ─→ localStorage queue ─→ POST /api/qso ─→ dequeue on success
│ │
│ ├─ no answer or 5xx: stays queued
│ └─ 4xx: kept, set aside, shown
└─ shown immediately, marked "Queued — syncing…"
The queue flushes on reconnect and can be retried manually. A QSO leaves the queue only when the server confirms it — an earlier version checked the wrong value and dropped QSOs whenever a submit failed, which is exactly the failure this design exists to prevent.
Replayed QSOs carry a replay flag. The server accepts those unconditionally,
bypassing the special event checkout and approval gates, because by reconnect
time the reservation has expired and the contact already happened on the air.
Refusing it would only lose the record.
A refusal the server means (a 4xx, other than 408 and 429) is not retried, because retrying cannot change the answer, and is not dropped either, because the contact happened on the air. It is set aside in the queue with the server's reason and shown in the log list until an operator re-logs it correctly and deletes it. Before this, a contact that could never be accepted sat at the head of the queue retrying every minute, and every contact queued behind it waited with it.
Every contact is still dated by the server's clock, but a queued one is dated
by how long it waited: the browser sends age_ms, the time between queueing it
and sending it, and the server stores NOW() - age. Both ends of that interval
are read from the same browser clock, so however wrong that clock is cancels
out, and the single-clock property the log depends on holds. Before this, a
contact queued through a twenty-minute outage was stamped with the reconnect
time. The WSJT-X relay does the same from WSJT-X's own logged time.
Duplicate contacts
Whether a contact is a dupe is a property of its group (the event, call, band
and mode, and on a DAY event the UTC date), not of the row, so it is settled
for the group. ezfd_recompute_dupes() in db/schema.sql ranks the group's
live contacts by time and flags all but the first; every insert, edit, delete,
restore, ADIF import and merge calls it for the groups it touched. The per-row
check it replaced could set a flag but never clear one, so deleting the first
of two contacts left the second flagged and scoring nothing. An insert takes a
transaction-scoped advisory lock on its group first, so two operators logging
the same station in the same instant cannot both be recorded as the one that
counts.
Special event call checkout
The rule is one signal per band per mode. Two operators claiming the same slot
at the same instant is a genuine race, and no application-level check closes
it — between the SELECT that finds the slot free and the INSERT that
claims it, the other operator can do both.
So the database enforces it:
CONSTRAINT ses_no_overlap EXCLUDE USING gist (
event_id WITH =, band WITH =, mode WITH =, during WITH &&
) WHERE (status <> 'RELEASED')
during is a tstzrange. The constraint makes overlapping rows impossible,
so the loser of a race gets SQLSTATE 23P01 and the route turns that into a
409 naming the current holder. It also rejects an extension that would run
into someone else's slot, which is the desired behaviour rather than a bug.
btree_gist is required for the = comparisons to sit alongside the range
overlap test in one GiST index.
See Database.
Rig control
The bridge runs entirely on the operator's machine:
radio ──serial──→ rigctld ──TCP──→ ezfd-rig-bridge.py ──WebSocket──→ browser
(localhost:4575)
The server is not involved and never learns anything about the radio. Both the main logging tab and the CW popout open their own connection to the bridge via a shared hook.
All rigctld I/O is serialised behind one lock, because polling and CW
commands share a connection and interleaving them corrupts both.
Layout
app/
api/ route handlers
event/[code]/ join, log, dashboard, CW popout
event/new/ event creation
components/ React components, all client-side
lib/ shared logic — scoring, exports, db, types
db/ schema and its tests
scripts/ test suites and the WSJT-X relay
lib/ holds anything that is or could be shared between routes and
components. Notable pieces:
| File | Responsibility |
|---|---|
scoring.ts |
The ARRL formula. The single source of truth for score |
adif.ts |
ADIF import parsing and export generation |
cabrillo.ts |
Cabrillo export (contest events only) |
ses.ts |
Checkout queries, the 23P01 constant, slot formatting |
events.ts |
Shared event column list, and the dupe rule |
offline-queue.ts |
The localStorage queue |
useRigBridge.ts |
WebSocket hook shared by both logging windows |
types.ts |
Domain types, band and mode lists, section list |
Things that bite
Collected here because they've each cost real debugging time. AGENTS.md
carries the full list for anyone changing the code.
pg returns TIMESTAMPTZ as JavaScript Date objects, not strings.
Export and formatting code has to handle both shapes. Interpolating one into a
message produces Sat Aug 22 2026 02:24:29 GMT+0000 (Coordinated Universal Time) rather than a time an operator can read.
Sections do not multiply the Field Day score. This has been implemented wrongly more than once. See Field Day.
Inline arrow-function props recreate identity every render. A parent that
re-renders on every rig frequency tick — four times a second — passing
foo={() => …} to a child whose effect depends on it will retrigger that
effect continuously. This once prevented an auto-CQ timer from ever completing
an interval. Memoise in the parent, or read the current value through a ref
inside long-lived timers.
json_agg over zero rows serialises as JSON null — a scalar, not SQL
NULL — so COALESCE(x, '[]') doesn't catch it and jsonb_array_elements
fails. Check jsonb_typeof instead.