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>
This commit is contained in:
2026-05-02 18:15:09 +02:00
parent 130b44778a
commit f92595a62a
8 changed files with 1144 additions and 21 deletions
+38 -19
View File
@@ -2,8 +2,8 @@
title: React SPA
type: entity
created: 2026-04-30
updated: 2026-04-30
sources: [gps-tracking-architecture]
updated: 2026-05-02
sources: [gps-tracking-architecture, traccar-maps-architecture]
tags: [service, presentation-plane, frontend]
---
@@ -26,31 +26,50 @@ One application serves multiple user types via role-based routing and conditiona
## Data access pattern
The SPA talks **exclusively** to Directus:
The SPA talks to **two endpoints**, one per plane (see [[live-channel-architecture]]):
- REST/GraphQL via `@directus/sdk`.
- WebSocket subscriptions via the same SDK.
- JWT auth managed by the SDK; refresh handled transparently.
- **[[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 the [[processor]], [[tcp-ingestion]], [[redis-streams]], or [[postgres-timescaledb]] directly. This boundary lets the back-end evolve internally and keeps the security model coherent — every request goes through Directus's permission system.
**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.
## Recommended stack
## Stack
- **Vite + React + TypeScript**
- **TanStack Router** — better TS support than React Router; optional file-based routing
- **TanStack Query** — server state, caching, invalidation, optimistic updates
- **@directus/sdk** — typed access + real-time
- **MapLibre GL + react-map-gl**open-source WebGL maps, no token needed
- **shadcn/ui + Tailwind** — UI primitives
- **Zustand** — client-only state (filters, UI prefs)
- **react-hook-form + Zod** — forms and validation
- **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 the spectrum from form-heavy admin screens to real-time map dashboards without architectural changes between them.
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
- **Live maps with many markers**: React reconciler is not the bottleneck — drawing happens in WebGL via MapLibre, which manages features outside React's tree. The React layer manages subscriptions and feeds the map updates.
- **High-frequency tabular updates** (live leaderboards, event feeds): split components so high-update areas re-render in isolation; use TanStack Query for live data; memoize at component boundaries that receive frequent updates.
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