Single binary serving the frontend too. In production, build React
(vite build → web/dist) and serve the static files from the same
Go process. In development, Vite runs on :5173 and proxies /api
to :8080, which avoids CORS conflicts. The production bundle must be
offline-capable: no CDN fonts or scripts (§7b).
WebSocket backpressure.dispatch uses
select { case c.send <- ev: default: drop } so a slow client never
blocks the others.
Client-side reconnect with exponential backoff plus automatic
resubscription on reconnect (the active subscriptions live in the
store).
Action idempotency. Every action from the client carries an id;
the server replies with ack. This is essential for debugging and for
optimistic UI updates.
Long operations (e.g. read CV) must not block the WebSocket
broadcast loop. Either expose them as REST with a timeout, or as a
WebSocket request/response with an id and a final ack carrying the
result. REST is the simpler default.
Type sync. Use tygo (or a small custom script) to generate
TypeScript types from Go structs that define the WS protocol. Keeping
it in sync by hand will degrade quickly.
Authentication & authorization. See §11 – login + PIN with strong
hashing (argon2id), session tokens carried as an HttpOnly cookie
for REST and as a ?token= query parameter for the WebSocket
upgrade. Permission checks live in dedicated middleware
(RequireRole, RequireVehicleAccess, …) so handlers stay thin.
SQLite migrations. Use REL's own migration tool
(github.com/go-rel/rel/cmd/rel); ship migration packages embedded
via embed.FS and run pending ones on startup.
Time-based grants cleanup. Temporary roles, leases and takeover
requests all carry an expires_at. A single janitor goroutine wakes
every 30 s, marks expired rows and emits the corresponding events
(role.expired, lease.expired) so the UI updates without a poll.
Lease expirations also call AuditService.Log with action
vehicle.lease_expired / train.lease_expired (§3a.5).
Audit log discipline. Every state-changing service call lists
in §3a.5 ends with an AuditService.Log(ctx, …) invocation. The
audit row carries the actor's denormalized login and the
object's denormalized name so renames or deletions never
rewrite history. The log is admin-only on read, with no UPDATE or
DELETE endpoints at all.
Internationalization. Frontend rendering is locale-aware,
backend is language-neutral. Stable codes on the wire
(ApiError.code, RadioPhrase, FunctionIcon, AuditAction,
…) are mapped to human strings by react-i18next from JSON
catalogues bundled into web/dist. Persisted denormalized
strings (audit user_name / object_name, RadioMessage.Note)
are rendered verbatim regardless of active locale. Full
specification, including the "what is translated vs. rendered
verbatim" contract, lives in §7c i18n.
scripts-executor supervision.server spawns the executor
child process at boot (exec.Command("loco", "scripts-executor",
"--executor-socket", socketPath)) and supervises it with
exponential backoff (1 s, 2 s, 4 s, …, capped at 30 s). On
successful dial of the RPC socket the supervisor flips the
executor.healthy flag and broadcasts system.status { scriptsExecutor:"healthy" }.
If the child exits unexpectedly, every in-flight runId is
marked executor_crashed, the owning sessions receive
script.runStopped { reason:"executor_crashed" }, and the
supervisor schedules a respawn. After 3 consecutive restarts
inside 60 s the supervisor stops respawning and emits
system.status { scriptsExecutor:"failed", reason } so the UI
can show a "Scripts unavailable, contact admin" banner; the
throttle stays fully functional. The supervisor also handles
graceful shutdown: on server SIGTERM it sends
executor.shutdown over RPC, waits up to 5 s for the executor
to drain in-flight runs (each run gets a run.stop { reason:"executor_shutdown" }),
then SIGKILL if needed.