Files
docs/wiki/entities/react-spa.md
T
julian f92595a62a docs: TRACCAR ingest + processor-ws-contract synthesis + auth-mode realignment
Catches up the wiki with several pieces of work accumulated during this
session.

INGEST: TRACCAR_MAPS_ARCHITECTURE.md
- raw/TRACCAR_MAPS_ARCHITECTURE.md (source doc, read-only).
- wiki/sources/traccar-maps-architecture.md — TL;DR + key claims +
  notable quotes + TRM divergences (PostGIS-native GeoJSON, rAF
  coalescer, Zustand, longer trail, racing sprite set).
- wiki/concepts/maps-architecture.md — distilled patterns for the SPA's
  map subsystem: singleton MapLibre + side-effect-only Map* components +
  two GeoJSON sources + style-swap mapReady gate + sprite preload + WS-
  to-map data flow (with rAF coalescer) + geofence editing + camera
  control trio.
- wiki/entities/react-spa.md — corrected the "talks exclusively to
  Directus" contradiction with [[live-channel-architecture]] (SPA
  connects to two endpoints — Directus + Processor); locked stack (raw
  MapLibre over react-map-gl, Zustand over Redux); added Auth section.
- wiki/concepts/live-channel-architecture.md — single sentence cross-
  referencing [[maps-architecture]] for consumer-side throughput
  discipline.
- index.md — Sources + Concepts entries.

SYNTHESIS: processor-ws-contract
- wiki/synthesis/processor-ws-contract.md — wire-level spec for the
  live-position WebSocket: endpoint, transport, auth handshake,
  subscribe/snapshot/streaming/unsubscribe protocol, reconnect, multi-
  instance behaviour, connection limits, versioning, open questions.
  Implementation-agnostic; the producer is cookie-name-agnostic so the
  spec doesn't pin to a specific Directus auth mode.
- index.md — Synthesis entry.

AUTH-MODE REALIGNMENT (cookie -> session)
- SPA implementation surfaced that Directus SDK 'cookie' mode doesn't
  survive a hard reload cleanly. Switched the SPA to 'session' mode
  (separate commit in trm/spa). Wiki updates here:
- wiki/entities/react-spa.md §Auth pattern — describes session mode
  (single httpOnly session cookie, no separate access token, no
  /auth/refresh dance). Added "Mode choice context" note.
- wiki/synthesis/processor-ws-contract.md §Auth handshake — emphasises
  the producer is cookie-name-agnostic; reframed "Cookie refresh while
  connected" as "Session expiry while connected".

Plus all the chronological log.md entries documenting the above plus
Phase 1.5 planning, SPA Phase 1 planning, and stage verify+seed work
from earlier in the session.

Skipped from this commit: .claude/agent-memory/* (user-local agent
state, not project content); .gitignore (already-modified by user
outside this session's scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:15:09 +02:00

6.3 KiB

title, type, created, updated, sources, tags
title type created updated sources tags
React SPA entity 2026-04-30 2026-05-02
gps-tracking-architecture
traccar-maps-architecture
service
presentation-plane
frontend

React SPA

The end-user experience for operators and external participants. Single React application, role-based views, served as a static bundle.

Why a separate SPA (not just the Directus admin UI)

The Directus admin UI is generic CRUD over collections — right for back-office editing and operators who think in records, wrong for end users who think in domain concepts. A dedicated SPA delivers:

  • Domain-shaped UX — screens organized around the user's mental model.
  • Independent deployment — front-end ships on its own cadence.
  • Targeted access control — public/partner-facing routes without exposing the admin surface.
  • Mobile/offline tuning — bundles tuned for the actual user environment.

Single app, role-based views

One application serves multiple user types via role-based routing and conditional UI. All users authenticate through directus; the SPA receives a JWT, reads role, and renders appropriate navigation/screens. Splitting into multiple apps is only justified when user populations are genuinely disjoint (public site vs. authenticated console) or when bundle size for one audience harms another.

Data access pattern

The SPA talks to two endpoints, one per plane (see live-channel-architecture):

  • directus — REST/GraphQL via @directus/sdk, plus Directus's WebSocket for business-plane events. Auth, schema, all CRUD on entries / vehicles / devices / geofences / etc.
  • processor — its own WebSocket endpoint, exclusively for the live position firehose. Authenticated by the same Directus-issued credential the SPA already holds; authorization delegated to Directus once at subscribe time.

Never talks to tcp-ingestion, redis-streams, or postgres-timescaledb directly. The two-endpoint split exists because Directus's WebSocket subscriptions only fire for writes through its own ItemsService — Processor's direct-to-DB position writes are invisible to it. See live-channel-architecture for why this is the architecturally honest answer rather than a workaround.

Stack

  • Vite + React + TypeScript — SPA build, no SSR.
  • TanStack Router — file-based, type-safe routes.
  • TanStack Query — Directus REST: caching, invalidation, refetch on focus.
  • @directus/sdk — typed access for REST + Directus's WebSocket.
  • MapLibre GL JS — WebGL map renderer. Used raw, not via react-map-gl — the declarative wrapper fights the imperative setData pattern that's the whole point of the architecture (see maps-architecture).
  • maplibre-google-maps (optional, runtime-config-gated) — protocol adapter that lets MapLibre consume Google's official Map Tiles API when an operator-provided API key is present.
  • @mapbox/mapbox-gl-draw — geofence editor (polygon / line / trash modes) wrapped to look native to MapLibre.
  • Zustand — high-frequency live state (positions, trails, selection). Granular subscribers; chosen over Redux specifically because Redux's dispatch + selector cascade is the most likely cause of the lag observed in production traccar-maps-architecture deployments.
  • shadcn/ui + Tailwind — UI primitives.
  • react-hook-form + Zod — forms and validation.

Covers form-heavy admin screens and real-time map dashboards without architectural changes between them.

Auth pattern

Same-domain session cookie via reverse proxy. One origin serves the SPA, Directus, and Processor's WebSocket endpoint — Vite's server.proxy in dev, Traefik (or whatever fronts the deploy stack) in stage/prod.

  • Login uses the Directus SDK in session mode (authentication('session', { credentials: 'include' })). Directus issues an httpOnly/Secure/SameSite=Lax session cookie; the cookie itself carries the session — there is no separate in-memory access token to manage and no /auth/refresh dance.
  • Reload survives cleanly: the browser still has the cookie, /users/me returns the user without any client-side state.
  • No JavaScript ever reads or writes the cookie (it's httpOnly), so XSS cannot exfiltrate it.
  • WebSocket handshake: same-origin means the browser sends the session cookie automatically with the upgrade request. Processor reads it on the upgrade, validates against Directus's /users/me, and uses the resulting user identity for subscription authorization. See live-channel-architecture and processor-ws-contract.

This requires the proxy to serve everything under one origin (path-based or single subdomain) — separate subdomains break cookie flow.

Mode choice context. Directus's SDK also supports 'cookie' mode (refresh cookie + in-memory access token). It works while the SDK is alive in memory but doesn't survive a hard reload cleanly because there's no access token to retry /users/me against, and the refresh-then-read sequence is order-sensitive. 'session' mode collapses that to one credential — the session cookie — and is the right default for an SPA that wants reload-survives behaviour.

Real-time rendering

The full pattern lives at maps-architecture; the headlines:

  • MapLibre is a singleton held in a module-level variable, attached to a detached <div> that React refs mount/unmount per page. WebGL context survives navigation.
  • Two GeoJSON sources for live positions: clustered non-selected, unclustered always-on-top selected. Updates flow through setData, not DOM marker manipulation.
  • rAF coalescer at the WS boundary. Incoming position messages buffer per-device; one requestAnimationFrame tick flushes the latest snapshot to the Zustand store. Without this, per-message dispatches cascade through selectors and setData at every position arrival — the failure mode traccar-maps-architecture exhibits.
  • Per-device bounded ring buffers for trail history. Default 200 points per device, configurable. The throttle controls visual cadence; trails are never lossy.
  • High-frequency tabular updates (live leaderboards, event feeds) — same Zustand store, separate component subtrees so the map's re-renders don't ripple into the leaderboard and vice versa.

Failure mode

UI unavailable → back-end unaffected. See failure-domains.