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:
@@ -116,7 +116,7 @@ The escape hatch is well-defined: lift the WebSocket endpoint code out of the Pr
|
||||
|
||||
- [[processor]] grows a public-facing WebSocket endpoint in addition to its existing Redis consumer and Postgres writer.
|
||||
- [[directus]] keeps its built-in WebSocket subscriptions for tables it writes to. Its real-time delivery section no longer claims to broadcast direct writes from [[processor]] — that's a documented mistake corrected in this revision.
|
||||
- [[react-spa]] connects to two WebSocket endpoints: Directus for admin/business updates, Processor for live position firehose. Same JWT-based auth on both.
|
||||
- [[react-spa]] connects to two WebSocket endpoints: Directus for admin/business updates, Processor for live position firehose. Same JWT-based auth on both. Consumer-side throughput discipline (rAF coalescing of incoming positions before reducer dispatch) is documented in [[maps-architecture]] — without it the per-message dispatch pattern observed in [[traccar-maps-architecture]] cascades through selectors and `setData` at every position arrival.
|
||||
- The deploy stack publishes the Processor's WebSocket port (with TLS termination at a reverse proxy in front).
|
||||
|
||||
## Why not a single WebSocket endpoint
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: Maps architecture
|
||||
type: concept
|
||||
created: 2026-05-02
|
||||
updated: 2026-05-02
|
||||
sources: [traccar-maps-architecture, gps-tracking-architecture]
|
||||
tags: [frontend, maps, maplibre, architecture, decision]
|
||||
---
|
||||
|
||||
# Maps architecture
|
||||
|
||||
The pattern the [[react-spa]] uses to render real-time positions, trails, geofences, and replay tracks. Inherited (with deliberate refinements) from [[traccar-maps-architecture]], which already fields the same shape — MapLibre GL JS + GeoJSON sources + WebSocket-fed updates — at production scale. Reading this page tells you how the SPA's maps subsystem is structured; reading the source tells you the original implementation in detail.
|
||||
|
||||
## Why this shape
|
||||
|
||||
DOM-rendered markers (Leaflet's default, or React-Leaflet's `<Marker>` components) thrash layout and repaint on every map move. ~1000 markers is enough to choke a browser. WebGL-rendered markers handle 100k+ at 60fps — the GPU is purpose-built for it. **The decision tree is short:** any map that updates frequently or shows more than a few hundred markers must be WebGL.
|
||||
|
||||
MapLibre GL JS is the open-source WebGL map renderer. Vector tiles, raster tiles, GeoJSON sources, symbol/circle/line/fill style layers — all GPU-rendered. Free, no API key required for OSM-derived tiles. Standard tooling.
|
||||
|
||||
The architecture that follows is what makes MapLibre tractable inside React without losing the imperative escape hatches you need for streaming-data performance.
|
||||
|
||||
## Singleton map
|
||||
|
||||
A **single `maplibregl.Map` instance lives for the entire app lifetime**, attached to a detached `<div>` held in a module-level variable. The `<MapView>` React component just appends that detached `<div>` into its own ref on mount and removes it on unmount; it then calls `map.resize()`.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Page navigation doesn't recreate the WebGL context.** Live → replay → geofence editor → reports — all share the same instance.
|
||||
- **Icon sprites stay registered across navigations** — no re-`addImage` cost per page change.
|
||||
- **State outside React's tree.** Sources, layers, camera position, event listeners are MapLibre's, not React's. React's reconciler doesn't compete.
|
||||
|
||||
Trade-off: if multiple tabs of the SPA need *different* maps (e.g. side-by-side comparison), the singleton breaks. For TRM's operator-facing flow this is a non-concern. Revisit only if a use case demands it.
|
||||
|
||||
## Side-effect-only `Map*` components
|
||||
|
||||
**Every component that participates in the map (`MapPositions`, `MapGeofences`, `MapTrails`, `MapAccuracy`, etc.) returns `null`.** It uses `useEffect` for setup (add source + layer) with cleanup (remove source + layer), and a separate `useEffect` for updates that calls `map.getSource(id)?.setData(...)`.
|
||||
|
||||
The two-effect pattern:
|
||||
|
||||
```ts
|
||||
function MapPositions({ positions }: Props) {
|
||||
const sourceId = useId();
|
||||
const layerId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
map.addSource(sourceId, { type: 'geojson', data: emptyFC, cluster: true });
|
||||
map.addLayer({ id: layerId, type: 'symbol', source: sourceId, layout: {...} });
|
||||
return () => {
|
||||
map.removeLayer(layerId);
|
||||
map.removeSource(sourceId);
|
||||
};
|
||||
}, []); // setup: runs once
|
||||
|
||||
useEffect(() => {
|
||||
map.getSource(sourceId)?.setData(toFeatureCollection(positions));
|
||||
}, [positions]); // update: runs on every change
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
`useId()` for source/layer ids lets the same component mount multiple times safely. The empty render keeps React's reconciler out of the rendering hot path.
|
||||
|
||||
## Two GeoJSON sources for live positions
|
||||
|
||||
Live device rendering uses **two GeoJSON sources**, not one:
|
||||
|
||||
- **Non-selected source** with `cluster: true, clusterMaxZoom: 14, clusterRadius: 50`. MapLibre's built-in clustering aggregates dense regions automatically.
|
||||
- **Selected source**, unclustered, always rendered on top.
|
||||
|
||||
This keeps the UX clean without manual z-ordering: clicking a device pulls it out of the clustered layer and into the always-on-top selected layer. Layers reference the appropriate source.
|
||||
|
||||
## Style swaps reset the world
|
||||
|
||||
Calling `map.setStyle(...)` wipes every custom source and layer the SPA has added. This is MapLibre's native behaviour, not a quirk to work around.
|
||||
|
||||
Coordination via a `mapReady` gate:
|
||||
|
||||
1. A module-level `ready` flag plus a listener `Set` lets components subscribe to global ready state.
|
||||
2. Basemap switcher fires `onBeforeSwitch → updateReadyValue(false)`. All children unmount and clean up their sources/layers.
|
||||
3. After `map.setStyle(...)` resolves (`styledata` event + `map.loaded()` polling), `initMap()` re-adds icon sprites, then `updateReadyValue(true)` flips the gate.
|
||||
4. `<MapView>` re-renders its `children`; every `Map*` component remounts and re-adds its source + layer.
|
||||
|
||||
The gate is the contract: **don't add anything to the map until `mapReady` is true after a style swap.** Violating it causes phantom-source errors that look like Heisenbugs.
|
||||
|
||||
## Icon sprites
|
||||
|
||||
Device-category icons (rally car / quad / SSV / motorcycle / runner / hiker for TRM, plus a generic `default`) are SVGs **pre-rasterised once at app startup** at `devicePixelRatio` scale, with all colour variants composited up-front, stored in a module-level registry, and `addImage`'d after every style swap.
|
||||
|
||||
Layers reference sprites by name in style expressions:
|
||||
|
||||
```ts
|
||||
'icon-image': ['concat', ['get', 'category'], '-', ['get', 'color']]
|
||||
```
|
||||
|
||||
So a feature with `properties.category === 'rally-car'` and `properties.color === 'success'` resolves to the `rally-car-success` sprite at GPU paint time. Zero per-frame work in JavaScript.
|
||||
|
||||
## WebSocket → map data flow (with rAF coalescer)
|
||||
|
||||
The end-to-end live data flow:
|
||||
|
||||
```
|
||||
Processor WS → coalesce buffer → Zustand store → useStore selectors → setData → GPU
|
||||
```
|
||||
|
||||
**The coalesce buffer is the discipline this architecture lives or dies on.** [[traccar-maps-architecture]] dispatches one Redux action per WS message; at 200 racers × 1Hz this fires 200 dispatches/sec, each cascading through `useSelector` consumers, `useFilter` recomputes over the full position array, and a freshly-built FeatureCollection passed to `setData`. That cascade is the most likely source of the lag operators see in production Traccar deployments.
|
||||
|
||||
TRM's pattern:
|
||||
|
||||
```ts
|
||||
const buffer = new Map<string, Position>(); // deviceId → latest position
|
||||
let rafScheduled = false;
|
||||
|
||||
socket.onmessage = (msg) => {
|
||||
const pos = JSON.parse(msg.data);
|
||||
buffer.set(pos.deviceId, pos);
|
||||
if (!rafScheduled) {
|
||||
rafScheduled = true;
|
||||
requestAnimationFrame(flush);
|
||||
}
|
||||
};
|
||||
|
||||
function flush() {
|
||||
rafScheduled = false;
|
||||
const snapshot = Array.from(buffer.values());
|
||||
buffer.clear();
|
||||
positionStore.getState().applyPositions(snapshot);
|
||||
}
|
||||
```
|
||||
|
||||
Properties of this shape:
|
||||
|
||||
- **At most one state update per frame** (~60Hz cap). Dozens of incoming messages per frame collapse to one snapshot.
|
||||
- **Per-device coalescing.** If the same device reports five times in 16ms, only the latest position is kept.
|
||||
- **No back-pressure on the WS.** Buffer is bounded by device count, not message rate.
|
||||
- **Trail history isn't lossy.** This buffer holds *latest visible positions for the map*, not the per-device trail. The trail is a separate per-device ring buffer in the store, written from `applyPositions`.
|
||||
|
||||
Throttling-by-user-choice (slider for "1Hz / 5Hz / 30Hz redraw rate") is a future feature layered on top of this — change the rAF callback to a `setInterval` keyed on user preference. The rAF coalescer is the always-on hygiene.
|
||||
|
||||
## State management
|
||||
|
||||
- **Zustand** for high-frequency live state: positions store, trails store, selection. Granular subscribers, no provider boilerplate, no `useSelector` cascades.
|
||||
- **TanStack Query** for Directus REST: events, classes, entries, vehicles, devices. Caching, invalidation, refetch on focus.
|
||||
- **No Redux.** The Traccar Redux pattern works correctly but the dispatch + selector machinery is overhead at our update rates and doesn't earn its weight given Zustand's existence.
|
||||
|
||||
## Geofence editing
|
||||
|
||||
`@mapbox/mapbox-gl-draw` wrapped to look native to MapLibre (CSS class patches). Modes: polygon, line_string, trash. On `draw.create` / `draw.update` / `draw.delete`, the SPA POSTs / PUTs / DELETEs to Directus's REST API. Directus stores PostGIS geometry directly; the SPA reads back GeoJSON via `ST_AsGeoJSON(geometry)` — **no WKT round-trip** unlike [[traccar-maps-architecture]] which uses WKT in storage.
|
||||
|
||||
Phase 2 of [[directus]] introduces the `geofences`, `waypoints`, and `speed_limit_zones` collections that this editor writes to.
|
||||
|
||||
## Camera control
|
||||
|
||||
Three components for three jobs, kept separate so each effect's dependency list is small and the wrong-camera-jump bugs that plague map UIs don't appear:
|
||||
|
||||
- **`MapCamera`** — one-shot fit. Builds an `LngLatBounds` from `coordinates` or `positions` props and calls `map.fitBounds(...)` once. Used in replay.
|
||||
- **`MapDefaultCamera`** — initial framing on first mount. Picks the selected device, the user's saved preference, or a fitBounds over visible positions. Runs at most once.
|
||||
- **`MapSelectedDevice`** — reactive follow. Watches the selected device id; on selection change or position change (with `mapFollow` on), `map.easeTo(...)`.
|
||||
|
||||
Bonus: `MapPadding` calls `map.setPadding({ left: drawerWidth })` so MapLibre's auto-centring accounts for the persistent left drawer in desktop layout.
|
||||
|
||||
## Tile sources
|
||||
|
||||
Two flavours, identical to [[traccar-maps-architecture]]:
|
||||
|
||||
- **Vector styles** for providers shipping a full MapLibre style JSON (OpenFreeMap, MapTiler, etc.). Best label/road/POI styling.
|
||||
- **Raster styles** synthesised ad-hoc with one raster source + one raster layer. Used for OSM, OpenTopoMap, Esri World Imagery, Google variants, Bing, custom URLs.
|
||||
|
||||
The synthesised raster style includes a `glyphs` URL pointing at a font CDN so even raster basemaps can render text labels for our overlays (geofence names, device labels, POIs).
|
||||
|
||||
**Google tiles via the official Map Tiles API** are usable through the `maplibre-google-maps` adapter, which registers a `google://` protocol handler that proxies to Google's authenticated tile endpoints. Bring-your-own-key model — Google API key in the SPA's runtime config, not baked into the image.
|
||||
|
||||
The dogfood-day starter set (subject to revision):
|
||||
|
||||
| Style | Provider | Cost | Notes |
|
||||
|---|---|---|---|
|
||||
| Satellite | Esri World Imagery (raster XYZ) | Free, attribution required | No key needed; default for first launch. |
|
||||
| Topo | OpenTopoMap (raster XYZ) | Free, attribution required | Useful in mountain rallies where pure satellite has cloud/shadow patches. |
|
||||
| Street | OSM raster | Free | Sanity baseline. |
|
||||
| Custom | User-supplied URL or style JSON | — | Operator escape hatch. |
|
||||
| Google Satellite (optional) | Google Map Tiles API via adapter | First 10k req/mo free, then per-tile | Enable only if operators provide a key in runtime config. |
|
||||
|
||||
## TRM divergences from [[traccar-maps-architecture]]
|
||||
|
||||
| Traccar | TRM | Reason |
|
||||
|---|---|---|
|
||||
| Geofence storage in WKT, WKT↔GeoJSON conversion in client | **Native PostGIS GeoJSON** via `ST_AsGeoJSON` | We have PostGIS deployed; the round-trip is dead weight. |
|
||||
| Redux dispatch per WS message | **rAF coalescer + Zustand store** | Eliminates the per-message cascade that drives Traccar's perceived lag. |
|
||||
| `liveRouteLength = 10` default | **`liveRouteLength = 200` default** | Rally operators want minutes of trail, not seconds. |
|
||||
| Generic fleet sprite set (car / truck / plane / ship / animal) | **Racing sprite set** (rally car / quad / SSV / motorcycle / runner / hiker / default) | Operators identify by category at a glance. |
|
||||
| `react-map-gl` is a candidate React wrapper | **Raw MapLibre + singleton + side-effect components** | Declarative wrappers fight the imperative `setData` pattern. |
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [[traccar-maps-architecture]] — the source architecture this concept distils.
|
||||
- [[react-spa]] — the entity that implements this concept.
|
||||
- [[live-channel-architecture]] — the producer-side WebSocket contract this concept consumes.
|
||||
- [[processor]] — produces the live position stream.
|
||||
- [[directus]] — REST API for geofence CRUD; JWT issuer for WS auth.
|
||||
+38
-19
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Traccar Web — Maps Architecture
|
||||
type: source
|
||||
created: 2026-05-02
|
||||
updated: 2026-05-02
|
||||
sources: []
|
||||
source_path: raw/TRACCAR_MAPS_ARCHITECTURE.md
|
||||
source_date: 2026-05-02
|
||||
source_kind: note
|
||||
tags: [maps, frontend, reference, architecture, maplibre]
|
||||
---
|
||||
|
||||
# Traccar Web — Maps Architecture
|
||||
|
||||
Internal architectural deep-dive into how `traccar-web` (the React front-end of the Traccar GPS tracking platform) builds its maps subsystem. Documents the rendering engine, tile-source strategies (including how Google's tiles are integrated despite Google not being a first-class MapLibre provider), GeoJSON-driven feature rendering, geofence editing, and the WebSocket → Redux → `setData` live-data pipeline.
|
||||
|
||||
This is the canonical reference architecture for [[react-spa]]. TRM's SPA inherits the bulk of these patterns and diverges in a small, deliberate set of places — see the divergences section below and the dedicated [[maps-architecture]] concept page.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Traccar's web app does **not** use the Google Maps JavaScript API. It uses **MapLibre GL JS** (a fork of pre-1.0 Mapbox GL JS) as a single, app-lifetime singleton WebGL renderer. Every map "object" — devices, geofences, routes, accuracy circles, POIs — is a feature in a GeoJSON source rendered by MapLibre style layers. Live data flows from a single WebSocket through Redux into `useSelector`-subscribed components that call `setData` on the appropriate source. Google tiles are consumed either via the official Map Tiles API (through the `maplibre-google-maps` adapter, which registers a `google://` protocol handler) or by hitting Google's legacy public tile servers directly when no API key is configured.
|
||||
|
||||
The architecture is well-designed for the rendering side. The likely failure mode at scale is the **per-message Redux dispatch + per-`setData` call** pattern in the data pipeline — at high position rates this cascades through selectors and rebuilds full feature collections on every position. TRM's SPA addresses that with a `requestAnimationFrame` coalescer at the WS boundary (see divergences).
|
||||
|
||||
## Key claims
|
||||
|
||||
Each claim is self-contained — readable without prior context.
|
||||
|
||||
### Rendering engine
|
||||
|
||||
- **MapLibre GL JS is the rendering engine**, not the Google Maps SDK or Leaflet. WebGL all the way down: vector and raster sources, GPU-rasterised symbol/circle/line layers, no DOM markers.
|
||||
- **A single `maplibregl.Map` instance lives for the entire app lifetime.** It's constructed at module load against a detached `<div>` held in a module-level variable. The React `<MapView>` component just appends that detached `<div>` into its own ref on mount and removes it on unmount, then calls `map.resize()`.
|
||||
- This means **navigating between pages (Main / Replay / Geofences / Reports) doesn't recreate the WebGL context, doesn't re-upload icon sprites, and doesn't refetch the style.** Significant perf win.
|
||||
- Every other map component in the codebase imports the same singleton: `import { map } from './core/MapView'` and calls `map.addSource`, `map.addLayer`, `map.on(...)` directly. They render `null` to React — they are imperative side effects wrapped in a component for lifecycle handling.
|
||||
|
||||
### `Map*` component contract
|
||||
|
||||
- **Every `Map*` component is a side-effect-only React component.** It returns `null` and uses `useEffect` to add sources/layers, with the cleanup function removing them.
|
||||
- **Two-effect pattern is consistent across the codebase:** one `useEffect` with `[]`-ish deps for setup (add source + layer), one with `[data]` deps for updates that just calls `map.getSource(id)?.setData(...)`.
|
||||
- **`useId()` is used to generate unique source/layer ids** so the same component can be mounted multiple times safely.
|
||||
|
||||
### Tile layers
|
||||
|
||||
- **Two flavours of basemap style:** vector styles (full MapLibre style JSON URLs from providers like OpenFreeMap, MapTiler, LocationIQ, TomTom, Ordnance Survey) and raster styles built ad-hoc by a `styleCustom({ tiles, minZoom, maxZoom, attribution })` helper.
|
||||
- Raster `styleCustom` is used for OSM, OpenTopoMap, Carto, **all three Google variants** (Road / Satellite / Hybrid), Bing, HERE, Yandex, AutoNavi, Mapbox raster styles, and any user-supplied custom URL.
|
||||
- The synthesised raster style includes a `glyphs` URL pointing at `cdn.traccar.com/map/fonts/...` so even raster-only basemaps can render text labels for overlays (geofences, devices, POIs).
|
||||
- **Google tiles are accessed two ways depending on whether the user has set a `googleKey`:**
|
||||
- **With key:** `tiles: ['google://roadmap/{z}/{x}/{y}?key=...']`. The `google://` protocol is intercepted by `maplibre-google-maps`'s `googleProtocol` handler, registered once globally via `maplibregl.addProtocol('google', googleProtocol)`. The handler calls Google's **official Map Tiles API** with session-token authentication. This is the legitimate, billable path.
|
||||
- **Without key:** `tiles: [0,1,2,3].map(i => 'https://mt${i}.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}&s=Ga')`. Hits Google's legacy public tile servers directly. Unauthenticated, ToS-grey, availability at Google's discretion.
|
||||
- Hybrid uses `satellite` + `&layerType=layerRoadmap` on the keyed path.
|
||||
- A user-configurable `custom` style branches: if `mapUrl` contains `{z}` or `{quadkey}` it's treated as a tile template via `styleCustom`; otherwise it's treated as a full style JSON URL.
|
||||
|
||||
### Style swaps reset the world
|
||||
|
||||
- **Setting a new style wipes all custom sources/layers.** This is MapLibre's standard behaviour, not a Traccar quirk.
|
||||
- A module-level `ready` flag plus a `Set<readyListeners>` lets React components subscribe to global ready state. The basemap switcher fires `onBeforeSwitch → updateReadyValue(false)`, which causes all child map components to unmount and clean up. After `map.setStyle(...)` the switcher waits for `styledata`, polls `map.loaded()` every 33 ms, then calls `initMap()` (which re-adds icon sprites via `map.addImage(...)`) and `updateReadyValue(true)`. Children remount and re-add their sources/layers.
|
||||
- Components that should persist across style swaps (`MapScale`, `MapCurrentLocation`, `MapGeocoder`, `MapNotification`) are added/removed by their own components, outside `<MapView>`'s children list.
|
||||
|
||||
### Icon sprites
|
||||
|
||||
- **All device category icons are SVGs** (`car`, `bus`, `truck`, `bicycle`, `plane`, `ship`, `person`, `animal`, ...) plus a generic `background.svg` and a `direction.svg` arrow.
|
||||
- **Pre-rasterised once at app startup** (`preloadImages()` in `src/index.jsx`):
|
||||
1. Each SVG is loaded to an `HTMLImageElement`.
|
||||
2. For each `category × colour` (`info`, `success`, `error`, `neutral`), `prepareIcon(background, icon, color)` draws the background to a canvas at `devicePixelRatio` scale, tints the icon with the colour using a `destination-atop` canvas trick, composites it centred, and stores the raw `ImageData` keyed `${category}-${color}` (e.g. `car-success`, `truck-error`).
|
||||
- `MapView.initMap` then calls `map.addImage(key, imageData, { pixelRatio })` for every entry once a style has loaded. After that, layers can reference any sprite by name in style expressions like `'icon-image': '{category}-{color}'`.
|
||||
|
||||
### Live device positions — `MapPositions`
|
||||
|
||||
- **Two GeoJSON sources, identified by `useId()`-based unique strings:**
|
||||
- `id` — non-selected devices, `cluster: true`, `clusterMaxZoom: 14`, `clusterRadius: 50`. MapLibre's built-in clustering handles aggregation.
|
||||
- `selected` — the currently selected device only, never clustered, always rendered on top.
|
||||
- Each source gets a symbol layer rendering `'icon-image': '{category}-{color}'` plus the title (device name or `fixTime`), and a `direction-…` symbol layer filtered to features where `direction === true`, drawing a direction arrow rotated by `course` with `'icon-rotation-alignment': 'map'`.
|
||||
- Plus a single `clusters` symbol layer on the main source filtered `['has', 'point_count']` showing a `background` icon with the count.
|
||||
- Feature properties drive layer expressions: `category` (selects sprite), `color` (`success`/`error`/`neutral`/`info` based on status or attribute override), `rotation` (course), `direction` (controlled by `mapDirection` user pref: `none` / `all` / `selected`).
|
||||
- **Updates flow through `setData`:** a second `useEffect` re-runs whenever `positions`, `devices`, `selectedPosition`, or `mapCluster` change, and calls `map.getSource(source)?.setData(...)` with a freshly built FeatureCollection for each source. MapLibre diffs and re-renders.
|
||||
|
||||
### Geofences (rendering and editing)
|
||||
|
||||
- **Three layers on a single GeoJSON source:** `geofences-fill` (semi-transparent polygon fill, opacity 0.1), `geofences-line` (per-feature `color`/`width`/`opacity` from attributes), `geofences-title` (symbol layer rendering `{name}`).
|
||||
- **Geofences arrive from the backend as WKT** in `item.area`. `geofenceToFeature(theme, item)` handles conversion: `CIRCLE(...)` strings are extracted as `(lat lon, radius)` and approximated as 32-step polygons in metres via `@turf/circle`; everything else is `wellknown.parse(item.area)` followed by `reverseCoordinates(...)` because **WKT is `lat lon` while GeoJSON is `lng lat`**.
|
||||
- The reverse mapping (`geometryToArea`) is used when saving edits.
|
||||
- **Editing uses `@mapbox/mapbox-gl-draw`** wrapped to look native to MapLibre (CSS class patches). Modes: polygon, line_string, trash. Listeners: `draw.create` → POST `/api/geofences`, `draw.update` → PUT, `draw.delete` → DELETE. On Redux geofence state change, Draw is cleared and re-populated with all features.
|
||||
|
||||
### Routes (history and live trails)
|
||||
|
||||
- **Replay route (`MapRoutePath`)**: builds a `FeatureCollection` of **per-segment** `LineString` features, one per consecutive position pair, and **colours each segment by the second point's speed** via `getSpeedColor(speed, minSpeed, maxSpeed)`. If the device has a `web.reportColor` attribute, that overrides speed-based colouring with a fixed colour for the whole track. Width/opacity come from user preferences.
|
||||
- **Replay points (`MapRoutePoints`)**: a symbol layer rendering the literal text `▲` as the marker, rotated by `course` and tinted by speed colour. Clicks emit `onClick(positionId, index)` so a slider can scrub to that point.
|
||||
- **Generic polyline (`MapRouteCoordinates`)**: takes pre-computed `coordinates` and renders a single `LineString` with optional name label.
|
||||
- **Live trails (`MapLiveRoutes`)**: reads from `state.session.history` — a Redux dictionary `{ [deviceId]: [[lon, lat], ...] }`. Updated inside `sessionActions.updatePositions`, **capped to `web.liveRouteLength` points per device (default 10)**. Behaviour gated by `mapLiveRoutes` user attribute: `none` / `selected` / all devices.
|
||||
- **Accuracy circles (`MapAccuracy`)**: for each position with `accuracy > 0` (in metres), `turfCircle([lon, lat], accuracy * 0.001)` polygon rendered as a translucent fill in the theme's geometry colour.
|
||||
|
||||
### Camera control
|
||||
|
||||
Three components for three jobs:
|
||||
|
||||
- **`MapCamera`** — one-shot fit. If passed `coordinates` or `positions`, builds an `LngLatBounds` and calls `map.fitBounds(bounds, { padding: min(w,h) * 0.1, duration: 0 })`. Used in `ReplayPage`.
|
||||
- **`MapDefaultCamera`** — initial framing on `MainPage`. Picks (priority order) the selected device's position, the user's saved `latitude/longitude/zoom` preference, or a `fitBounds` over all visible positions. Runs at most once.
|
||||
- **`MapSelectedDevice`** — reactive follow. Watches `state.devices.selectedId`, `selectTime`, and the position of the selected device. Fires `map.easeTo(...)` when the user reselects/re-clicks or when `mapFollow` is on and the selected device's coordinates change.
|
||||
|
||||
### Live data pipeline
|
||||
|
||||
End-to-end flow for "a device moved on the screen":
|
||||
|
||||
```
|
||||
WebSocket /api/socket → SocketController.jsx → dispatch(...)
|
||||
→ Redux store (session.positions, session.history, devices, events)
|
||||
→ useSelector in MapPositions / MapLiveRoutes / MapSelectedDevice
|
||||
→ map.getSource(id).setData(FeatureCollection)
|
||||
→ MapLibre re-renders (WebGL)
|
||||
```
|
||||
|
||||
- `SocketController.jsx` opens `wss?://<host>/api/socket` once authenticated. On `onmessage`, parses the JSON envelope and dispatches one of: `devicesActions.update`, `sessionActions.updatePositions`, `eventsActions.add`, `sessionActions.updateLogs`.
|
||||
- Reconnect on `onclose` with a 60 s loop; `online` and `visibilitychange` listeners re-test the socket; periodic `socket.send('{}')` ping.
|
||||
- `sessionActions.updatePositions` reducer per incoming position: (a) `state.positions[deviceId] = position` overwrites the latest; (b) if `mapLiveRoutes` is on, appends `[longitude, latitude]` to `state.history[deviceId]` capped to `liveRouteLength`; same-coord-as-last is skipped.
|
||||
- `MainPage` reads `positions`, runs `useFilter` to compute `filteredPositions`, passes them into `<MainMap>` which composes `MapView`, `MapOverlay`, `MapGeofence`, `MapAccuracy`, `MapLiveRoutes`, `MapPositions`, `MapDefaultCamera`, `MapSelectedDevice`, `PoiMap` plus auxiliary controls.
|
||||
|
||||
### Conventions to keep in mind (verbatim from the doc)
|
||||
|
||||
1. Every `Map*` component is a side-effect-only React component returning `null` and using `useEffect` to add sources/layers.
|
||||
2. `useId()` is used to generate unique source/layer ids so the same component can be mounted multiple times safely.
|
||||
3. All data updates flow through GeoJSON `setData` — no direct DOM marker manipulation.
|
||||
4. Style swaps reset the world; anything custom must be re-added after `setStyle`. The `mapReady` gate coordinates this.
|
||||
5. Coordinates everywhere are `[lon, lat]` (MapLibre/GeoJSON convention). `reverseCoordinates` exists specifically to bridge the WKT `lat lon` ordering used by Traccar's geofence storage.
|
||||
6. No Google Maps SDK is loaded in the browser. Even when Google tiles are used, the path is `tile URL → fetch → blob → MapLibre raster source`. The only Google-specific code is the protocol adapter from `maplibre-google-maps`.
|
||||
|
||||
## Notable quotes
|
||||
|
||||
> A **single** `maplibregl.Map` instance lives for the entire app lifetime, attached to a **detached `<div>`** held in a module-level variable. The React `<MapView>` component just mounts that detached `<div>` into its own ref (`appendChild`) on mount and removes it on unmount, then calls `map.resize()`. (§2)
|
||||
|
||||
> All data updates flow through GeoJSON `setData` — there is no direct DOM marker manipulation. This keeps the WebGL pipeline efficient and lets MapLibre handle clustering/visibility. (§14)
|
||||
|
||||
> No Google Maps SDK is loaded in the browser. Even when Google tiles are used, the code path is `tile URL → fetch → blob → MapLibre raster source`. The only Google-specific code is the protocol adapter from `maplibre-google-maps`, which is registered once in `MapView.jsx`. (§14)
|
||||
|
||||
## TRM divergences
|
||||
|
||||
The TRM SPA inherits the bulk of this architecture and diverges in a small, deliberate set of places. Each divergence is anchored to a specific reason, not "I prefer X."
|
||||
|
||||
| Traccar pattern | TRM pattern | Reason |
|
||||
|---|---|---|
|
||||
| Geofence storage in WKT (`item.area` is a WKT string); client converts WKT → GeoJSON on every read and GeoJSON → WKT on every save via `wellknown` + `reverseCoordinates`. | **Native PostGIS GeoJSON.** The API serves `ST_AsGeoJSON(geometry)` directly; client receives valid GeoJSON, no conversion. | Traccar predates PostGIS being a first-class option in many environments; we have it deployed and the round-trip is dead weight. |
|
||||
| `CIRCLE(lat lon, radius)` WKT extension approximated client-side as a 32-step polygon via `@turf/circle`. | **Real `geometry(POLYGON, 4326)` columns**, with circles either stored as polygons up front or computed server-side via `ST_Buffer` if needed. | Same reason as above; native geometry types remove the approximation step. |
|
||||
| Redux dispatch on every WebSocket message arrival. At 200 racers × 1Hz that's 200 dispatches/sec, each cascading through `useSelector`, `useFilter`, and a freshly-built FeatureCollection in `setData`. | **`requestAnimationFrame`-coalescing buffer at the WS boundary.** WS messages push into a per-device map; an rAF loop fires once per frame (~16ms), reads the buffer, and triggers a single state update. | Traccar's lag at high update rates is almost certainly here, not in the GPU pipeline. ~30 lines of code, eliminates the cascade. |
|
||||
| Redux for everything (positions, history, devices, events, session, UI prefs). | **Zustand for high-frequency live state** (positions, trails); **TanStack Query for Directus REST**; Redux not used. | Zustand stores can be subscribed to with selectors that don't re-run components unnecessarily; meaningful render-cost reduction at the rates we expect. |
|
||||
| `liveRouteLength` default 10 — barely a trail. | **Default 200 points per device**, configurable. | Rally operators want a few minutes of trail visible to read what a racer is doing; 10 points at 1Hz is 10 seconds. |
|
||||
| Icon sprite set covers general fleet (car, bus, truck, plane, ship, animal). | **Racing-specific sprite set** (rally car, quad / ATV, SSV / UTV, motorcycle, runner, hiker) plus the generic `default`. | Race operators identify by category at a glance; "truck" and "plane" sprites would be misleading. |
|
||||
| `react-map-gl` could be a candidate React wrapper for MapLibre. | **Raw MapLibre via the singleton + side-effect components pattern** (Traccar's choice). No `react-map-gl`. | The declarative wrapper fights the imperative `setData` pattern that's the whole point of the architecture. The Traccar approach is cleaner and gives full control. |
|
||||
|
||||
## Open questions surfaced by this ingest
|
||||
|
||||
- **Clustering parameters.** Traccar uses `clusterMaxZoom: 14`, `clusterRadius: 50`. Rally racing density (50–500 vehicles spread across a country-scale stage) may want different values. Defer until we have a real map open with real positions.
|
||||
- **Basemap switcher scope for v1.** Traccar offers 30+ basemap options; for the dogfood we don't need that. Decide a starter set: probably one satellite (Esri or Google via adapter), one topo (OpenTopoMap), one street (OSM), and the "custom URL" escape hatch.
|
||||
- **Sprite set finalisation.** Rally / quad / SSV / motorcycle / runner / hiker covers the dogfood disciplines. Need actual SVG assets — borrow from open icon sets or commission.
|
||||
- **Live trail per-segment colouring.** Traccar colours replay segments by speed. For live trails it just renders a flat colour. Adopting the per-segment-by-speed pattern for live too would be a small upgrade — race operators glance at colour and immediately see who's pushing vs. cruising.
|
||||
- **`@mapbox/mapbox-gl-draw` license check.** It's open source but worth confirming the licence is compatible with our deployment (Mozilla Public License 2.0, last we checked).
|
||||
- **Geocoder.** Traccar uses Nominatim via `MaplibreGeocoder`. For Albania-specific use we may want a different gazetteer, or skip search entirely for v1.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [[react-spa]] — the entity that consumes this reference architecture.
|
||||
- [[maps-architecture]] — concept page distilling the patterns from this source plus TRM's refinements.
|
||||
- [[live-channel-architecture]] — TRM's WS contract on the producer (Processor) side; this source documents the consumer side.
|
||||
- [[processor]] — produces the live position stream this architecture consumes.
|
||||
- [[directus]] — issues the JWT used for WS auth.
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
title: Processor WebSocket contract
|
||||
type: synthesis
|
||||
created: 2026-05-02
|
||||
updated: 2026-05-02
|
||||
sources: [gps-tracking-architecture, traccar-maps-architecture]
|
||||
tags: [websocket, protocol, contract, telemetry-plane, decision]
|
||||
---
|
||||
|
||||
# Processor WebSocket contract
|
||||
|
||||
The wire-level specification of the WebSocket endpoint that fans live position updates from [[processor]] (or its eventual replacement gateway — see Implementation status) to [[react-spa]] clients. Both sides build against this contract; changes require a coordinated update on both sides.
|
||||
|
||||
This page is the protocol spec. The architectural rationale lives in [[live-channel-architecture]]; the consumer-side rendering pattern in [[maps-architecture]]; the inheritance from a working production reference in [[traccar-maps-architecture]].
|
||||
|
||||
## Implementation status
|
||||
|
||||
**Planned as `processor` Phase 1.5 — Live broadcast.** Six tasks in `trm/processor/.planning/phase-1-5-live-broadcast/`: WS server scaffold + heartbeat, cookie auth handshake, subscription registry & per-event authorization, broadcast consumer group & fan-out, snapshot-on-subscribe, integration test. Status ⬜ Not started; sequenced as 1.5.1 → 1.5.2 → 1.5.3 → (1.5.4 ‖ 1.5.5) → 1.5.6.
|
||||
|
||||
The endpoint is hosted *inside* the Processor process (as [[processor]] and [[live-channel-architecture]] specify). Lifting it into a separate `live-gateway` service is the documented escape hatch in [[live-channel-architecture]] §"Scale considerations" if sustained > 10k WS messages/sec demands it — not the starting point.
|
||||
|
||||
This contract is implementation-agnostic in the sense that the wire format wouldn't change if we ever did lift the endpoint out — only the host process would. SPA work can build against the contract independently of the Processor task sequence as long as it doesn't ship to stage before Phase 1.5 lands.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
wss://<one-public-origin>/processor/ws
|
||||
```
|
||||
|
||||
Served behind the same reverse proxy that fronts [[directus]] and the [[react-spa]] static bundle. **Single origin is non-negotiable** — same-origin is what allows the auth cookie to flow with the WebSocket upgrade request (see Auth handshake below).
|
||||
|
||||
The path `/processor/ws` is illustrative; final path determined by the proxy routing rules. Whatever it is, the SPA reaches it as a relative URL, never a cross-origin URL.
|
||||
|
||||
## Transport
|
||||
|
||||
- **Protocol:** WebSocket (RFC 6455) over TLS at the edge. Internal hop from the proxy to the producer is plain WS on the `trm_default` Compose network.
|
||||
- **Subprotocol:** none required. Future versions may add a `Sec-WebSocket-Protocol` of `trm.live.v1` if we need to negotiate versions; for now the path is the version.
|
||||
- **Frame format:** text frames, JSON-encoded. No binary frames. (If we ever need to ship raw position bytes for a high-frequency optimisation, that's a v2 concern.)
|
||||
- **Heartbeat:** the producer sends a ping every 30 s; the consumer responds. Consumer-side liveness is enforced by `setInterval` checking time-since-last-message > 60s ⇒ reconnect.
|
||||
|
||||
## Auth handshake
|
||||
|
||||
Cookie-based, same-origin, validated against [[directus]] once at connection time. The SPA uses the Directus SDK in session mode (see [[react-spa]] §"Auth pattern"); the producer is cookie-name-agnostic and just forwards whatever cookie header the upgrade carries.
|
||||
|
||||
```
|
||||
1. Browser opens WebSocket to wss://<origin>/processor/ws.
|
||||
Same-origin → browser automatically attaches the httpOnly session cookie
|
||||
issued by Directus's /auth/login (session mode).
|
||||
|
||||
2. Producer reads the entire Cookie header from the upgrade request.
|
||||
GET /users/me to Directus, forwarding the header verbatim.
|
||||
200 → user identity (id, role, etc.) is bound to the connection.
|
||||
401/403 → close the WebSocket with code 4401 (unauthorized).
|
||||
|
||||
3. Connection is now authenticated. The producer holds (connectionId → user)
|
||||
in memory. No further per-message auth.
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- **Cookie validation cache.** `/users/me` round-trip per connection is fine at pilot scale (≤500 viewers). At higher scale, cache the validation result for the connection's lifetime; on logout / session expiry the SPA reconnects, which re-validates.
|
||||
- **No JWT in URL.** Don't pass tokens in query strings — they end up in proxy logs. Cookie is the only credential.
|
||||
- **Why cookie not Authorization header.** Browsers don't let you set Authorization on a WebSocket upgrade. Cookies flow automatically. Same-origin is what makes this work.
|
||||
- **Cookie-name-agnostic.** The producer never parses individual cookies; it forwards the whole header to `/users/me` and lets Directus identify the session. This keeps the producer working unchanged if Directus's cookie name or auth-mode default ever changes.
|
||||
|
||||
## Subscription model
|
||||
|
||||
After authentication, the SPA subscribes to event-scoped topics. One connection can hold multiple subscriptions; per-event authorization is checked once at subscribe time.
|
||||
|
||||
### Topic format
|
||||
|
||||
```
|
||||
event:<eventId>
|
||||
```
|
||||
|
||||
`<eventId>` is the UUID of an `events` row. Authorization: the user must have a record in `organization_users` for the event's organization (any role). Phase 4 of [[directus]] (permissions) will tighten this; for now membership is enough.
|
||||
|
||||
Future topic shapes (not in v1):
|
||||
|
||||
- `device:<deviceId>` — single-device follow.
|
||||
- `entry:<entryId>` — follow a specific competitor across stages.
|
||||
- `org:<orgId>` — broad org-wide watch (admin-only).
|
||||
|
||||
The protocol is forward-compatible: any string-typed topic is valid; producer rejects unknown shapes with `error/unknown-topic`.
|
||||
|
||||
### Subscribe
|
||||
|
||||
```json
|
||||
// Client → Server
|
||||
{
|
||||
"type": "subscribe",
|
||||
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||
"id": "client-correlation-id-1"
|
||||
}
|
||||
```
|
||||
|
||||
`id` is optional; if present, the server echoes it on the response so the client can correlate.
|
||||
|
||||
### Server response — subscribed
|
||||
|
||||
```json
|
||||
// Server → Client
|
||||
{
|
||||
"type": "subscribed",
|
||||
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||
"id": "client-correlation-id-1",
|
||||
"snapshot": [
|
||||
{ "deviceId": "cbed320e...", "lat": 41.327, "lon": 19.819, "ts": 1714654800000, "speed": 42.3, "course": 187, "accuracy": 5.0, "attributes": {} },
|
||||
{ "deviceId": "f6114c7e...", "lat": 41.328, "lon": 19.820, "ts": 1714654799000, "speed": 38.1, "course": 184, "accuracy": 4.5, "attributes": {} }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The snapshot is the **latest known position per device** registered to the event (via `entry_devices` → `entries` → `events`). Without it, the SPA opens to a black map until devices report — feels broken.
|
||||
|
||||
### Server response — error
|
||||
|
||||
```json
|
||||
// Server → Client
|
||||
{
|
||||
"type": "error",
|
||||
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||
"id": "client-correlation-id-1",
|
||||
"code": "forbidden",
|
||||
"message": "User does not belong to the event's organization."
|
||||
}
|
||||
```
|
||||
|
||||
Error codes (initial set; extensible):
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `forbidden` | User authenticated but not authorized for this topic. |
|
||||
| `not-found` | Topic refers to a non-existent entity (event id has no row). |
|
||||
| `unknown-topic` | Topic format not recognised. |
|
||||
| `rate-limited` | Subscribe rate exceeded (Phase 3 hardening; reserved). |
|
||||
|
||||
### Streaming updates
|
||||
|
||||
After `subscribed`, the server pushes one message per position-of-interest:
|
||||
|
||||
```json
|
||||
// Server → Client
|
||||
{
|
||||
"type": "position",
|
||||
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||
"deviceId": "cbed320e-1e94-488a-93c3-41060fcb06bc",
|
||||
"lat": 41.32791,
|
||||
"lon": 19.81947,
|
||||
"ts": 1714654801000,
|
||||
"speed": 42.5,
|
||||
"course": 188,
|
||||
"accuracy": 5.0,
|
||||
"attributes": {}
|
||||
}
|
||||
```
|
||||
|
||||
Field semantics:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `type` | `"position"` | yes | Discriminator. |
|
||||
| `topic` | string | yes | Echoes the subscription. Allows multiplexing on one connection. |
|
||||
| `deviceId` | uuid | yes | The `devices.id` (not the IMEI). SPA looks up device → entry → vehicle/crew via TanStack Query against [[directus]]. |
|
||||
| `lat` / `lon` | number (degrees, WGS84) | yes | GPS coordinates. **Coordinate order in JSON is `lat`/`lon`** (not `[lon,lat]` GeoJSON ordering — that conversion happens in the SPA). |
|
||||
| `ts` | number (epoch milliseconds, UTC) | yes | Authoritative timestamp from the device's GPS fix. **Always use this, never `Date.now()` on the client.** |
|
||||
| `speed` | number (km/h) | optional | Omitted if device reports speed=0 with invalid GPS fix (per [[teltonika]] convention). |
|
||||
| `course` | number (degrees, 0=N, clockwise) | optional | Heading. Omitted if unknown. |
|
||||
| `accuracy` | number (metres) | optional | Position accuracy radius for the [[react-spa]]'s accuracy-circle layer. |
|
||||
| `attributes` | object | optional, default `{}` | The decoded IO bag. Phase 1 ships the raw IO map; Phase 2 of [[processor]] adds named attributes per [[io-element-bag]]. SPA must tolerate empty / unknown shapes. |
|
||||
|
||||
The producer should **omit fields rather than send `null`** for absent values. Reduces JSON size and removes ambiguity (null = "we don't know" vs missing = "device didn't report").
|
||||
|
||||
### Unsubscribe
|
||||
|
||||
```json
|
||||
// Client → Server
|
||||
{
|
||||
"type": "unsubscribe",
|
||||
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||
"id": "client-correlation-id-2"
|
||||
}
|
||||
```
|
||||
|
||||
Server response:
|
||||
|
||||
```json
|
||||
// Server → Client
|
||||
{
|
||||
"type": "unsubscribed",
|
||||
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||
"id": "client-correlation-id-2"
|
||||
}
|
||||
```
|
||||
|
||||
The connection stays open with whatever other subscriptions are active. Closing the WebSocket is the cleanup-everything path.
|
||||
|
||||
## Reconnect semantics
|
||||
|
||||
The client reconnects on close (other than code 4401). Backoff: 1s, 2s, 4s, 8s, 16s, then 30s steady. Cap at 30s.
|
||||
|
||||
On reconnect, the client **must re-subscribe to all previously-active topics**. The server treats reconnect as a fresh connection; subscription state lives in memory only.
|
||||
|
||||
The server should accept reconnects from the same user without rate-limiting at pilot scale. Phase 3 may add a per-user concurrent-connection cap.
|
||||
|
||||
## Multi-instance behaviour
|
||||
|
||||
When [[processor]] (or the gateway service) runs more than one replica:
|
||||
|
||||
- Each instance reads the [[redis-streams]] telemetry stream on **two consumer groups**:
|
||||
- `processor` — the durable-write group (work-split: only one instance handles each record for the DB write).
|
||||
- `live-broadcast-{instance_id}` — a per-instance fan-out group (every instance reads every record for fan-out).
|
||||
- Connected clients are bound to one instance via the load balancer; that instance fans out to its own clients only. No cross-instance broadcasting needed.
|
||||
- The reconnect is what handles instance failure — client reconnects, gets re-load-balanced to a healthy instance, re-subscribes.
|
||||
|
||||
This design is documented in [[live-channel-architecture]] §"Multi-instance Processor".
|
||||
|
||||
## Connection limits and back-pressure
|
||||
|
||||
Pilot-scale targets (subject to revision after first dogfood):
|
||||
|
||||
| Metric | Target |
|
||||
|---|---|
|
||||
| Concurrent connections per instance | 100 |
|
||||
| Subscriptions per connection | 4 (one event + room for future per-device follow) |
|
||||
| Position messages per second per connection | ≤ 500 (race start with 500 devices reporting at 1Hz) |
|
||||
| End-to-end latency (Redis stream → client) | p95 < 500ms |
|
||||
| Reconnect storm tolerance | 200 reconnects/sec for 5 seconds (race start surge) |
|
||||
|
||||
If a slow consumer can't drain its queue, the server **drops oldest position messages** for that connection (per-device; latest position is always preserved). Position data is always-fresh — backlog isn't valuable. Only `subscribed`/`unsubscribed`/`error` control messages are guaranteed delivery.
|
||||
|
||||
## Versioning
|
||||
|
||||
This is `v1`. Breaking changes (renaming fields, changing semantics) require:
|
||||
|
||||
1. New endpoint path (`/processor/ws/v2`).
|
||||
2. Update this synthesis page to document both versions.
|
||||
3. Deprecation window: v1 stays online for ≥ one full event cycle after v2 lands.
|
||||
|
||||
Non-breaking additions (new optional fields, new message types, new error codes) ship in v1 without ceremony — both sides should ignore unknown fields and unknown `type` values.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Session expiry while connected.** Directus session cookies have a finite lifetime. The WebSocket connection's already-validated identity is unaffected for as long as the connection stays open — the producer authorised once at upgrade and doesn't re-check. If the session expires server-side, the SPA's next REST call (or its periodic `/users/me` ping, if added) will fail with 401, the SPA will redirect to login, and on re-login the SPA reconnects the WebSocket — which re-validates. Pilot answer: producer never re-validates mid-connection. Phase 3 hardening can revisit if real-world session durations make this feel wrong.
|
||||
- **Device-to-event resolution snapshot freshness.** The snapshot includes "every device registered to the event"; that registration set may change while a client is subscribed. Initial answer: subscription holds the registration set captured at subscribe time; new entries added mid-event don't appear until the client reconnects. Acceptable for pilot.
|
||||
- **Faulty-flag visibility.** When an operator flips a position's `faulty=true` flag in [[directus]], should the live channel emit a correction? Current answer: no — faulty flagging is post-hoc operator review, not a live concern. Live map shows whatever was streamed at the time. The recompute pipeline ([[processor]] faulty position handling) corrects derived data, not the live history.
|
||||
- **Replay-mode endpoint.** Out of v1 scope. A future `event:<id>:replay` topic could stream historical positions at a chosen speed. Defer.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [[live-channel-architecture]] — architectural rationale and dual-channel design.
|
||||
- [[processor]] — the entity nominally hosting this endpoint (subject to the Implementation status note above).
|
||||
- [[react-spa]] — the consumer.
|
||||
- [[maps-architecture]] — consumer-side throughput discipline (rAF coalescer) that this contract is consumed through.
|
||||
- [[traccar-maps-architecture]] — the working production reference whose WS contract shape this draws from (with refinements for our needs).
|
||||
- [[directus]] — auth source (cookie validator) and the data source for event/device/org metadata the SPA looks up alongside the live stream.
|
||||
Reference in New Issue
Block a user