tutorial_mission_control_dashboard.org
1 #+title: Tutorial: Build a Mission Control Dashboard 2 #+author: Claude Code 3 #+date: [2026-04-01 Wed] 4 #+startup: indent 5 #+options: toc:t num:t ^:{} 6 7 * What You Will Learn 8 9 By the end of this tutorial you will have built a real-time mission control 10 dashboard that: 11 12 - Displays live aircraft on an interactive map using the OpenSky Network API 13 - Streams simulated mission assets over WebSocket from your own server 14 - Consumes Boston transit vehicle positions via Server-Sent Events (MBTA API) 15 - Shows alert notifications in a sidebar 16 - Displays detailed telemetry for a selected asset 17 18 You will learn three distinct real-time data transport patterns (polling, 19 WebSocket, SSE) and how to integrate them into a single application using 20 TanStack Start for routing and server functions, XState machines for connection 21 lifecycle, and =@xstate/store= for shared application state. 22 23 * Prerequisites 24 25 - Node.js latest installed 26 - A code editor (gnu Emacs recommended) 27 - Basic familiarity with React and TypeScript (you know what a component is, 28 what props are) 29 - A free OpenSky Network account: https://opensky-network.org/login 30 - A free MBTA API key: https://api-v3.mbta.com/ (click "Request an API key") 31 - A VPS with Node.js available (for Phase 2) — any Linux server works 32 - ~2 hours for the full tutorial (each phase is ~40 minutes) 33 34 * Phase 1: Aircraft on a Map (Polling) 35 36 In this phase you will fetch live aircraft positions from the OpenSky Network 37 API every 10 seconds and render them on a map. You will use a TanStack Start 38 server function to proxy the API call (keeping credentials server-side) and an 39 XState machine to manage the polling lifecycle. 40 41 ** Step 1: Scaffold the Project 42 43 #+begin_src shell 44 npx @tanstack/cli@latest create mission-control 45 #+end_src 46 47 #+begin_example 48 ✔ Project created 49 #+end_example 50 51 Check that you can start the default app 52 #+begin_src bash 53 cd mission-control 54 npm run dev 55 #+end_src 56 57 #+caption: open localhost:3000 58 #+name: screenshot default app 59 [[../../assets/TanStack Start Starter.png]] 60 61 # image and link preview C-c C-x C-v (org-link-preview) 62 The CLI scaffolds a TanStack Start project with file-based routing already 63 configured. Your project structure looks like this: 64 65 #+begin_example 66 mission-control/ 67 ├── src/ 68 │ ├── routes/ 69 │ │ ├── __root.tsx ← root layout (HTML shell) 70 │ │ └── index.tsx ← home page route 71 │ ├── router.tsx ← router configuration 72 │ └── routeTree.gen.ts ← auto-generated route tree 73 ├── vite.config.ts 74 ├── tsconfig.json 75 └── package.json 76 #+end_example 77 78 Install the mapping library, XState, and the store: 79 80 #+begin_src bash 81 npm install leaflet react-leaflet xstate @xstate/react @xstate/store 82 npm install -D @types/leaflet 83 #+end_src 84 85 We install three XState packages: =xstate= + =@xstate/react= for state machines 86 (connection lifecycle), and =@xstate/store= for shared application state. See 87 [[file:../decisions/adr_001_state_store_selection.org][ADR-001]] for why we chose =@xstate/store= over Zustand or React Context. 88 89 ** Step 2: Set Up the Root Layout 90 91 Open =src/routes/__root.tsx=. This is the HTML shell for your entire app. 92 Replace its contents: 93 94 #+begin_src js 95 // src/routes/__root.tsx 96 /// <reference types="vite/client" /> 97 import type { ReactNode } from "react"; 98 import { 99 Outlet, 100 createRootRoute, 101 HeadContent, 102 Scripts, 103 } from "@tanstack/react-router"; 104 105 export const Route = createRootRoute({ 106 head: () => ({ 107 meta: [ 108 { charSet: "utf-8" }, 109 { name: "viewport", content: "width=device-width, initial-scale=1" }, 110 { title: "Mission Control Dashboard" }, 111 ], 112 links: [ 113 { 114 rel: "stylesheet", 115 href: "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", 116 }, 117 ], 118 }), 119 component: RootComponent, 120 }); 121 122 function RootComponent() { 123 return ( 124 <RootDocument> 125 <Outlet /> 126 </RootDocument> 127 ); 128 } 129 130 function RootDocument({ children }: Readonly<{ children: ReactNode }>) { 131 return ( 132 <html> 133 <head> 134 <HeadContent /> 135 </head> 136 <body style={{ margin: 0 }}> 137 {children} 138 <Scripts /> 139 </body> 140 </html> 141 ); 142 } 143 #+end_src 144 145 TanStack Start uses =__root.tsx= as the layout wrapper for every page. The 146 =<Outlet />= component renders whichever child route matches the current URL. 147 The =<HeadContent />= component injects the meta tags and links you defined in 148 the =head= function. The =<Scripts />= component handles hydration. 149 150 ** Step 3: Create the Map Component 151 152 *Goal:* render an empty interactive map centered on Switzerland, living 153 inside the =Header= / =Footer= chrome that Step 2 wired into =__root.tsx=. 154 No markers yet. By the end of this step you should be able to pan and zoom 155 an OpenStreetMap tile layer in your browser. 156 157 This step looks like "drop in a component", but three small decisions hide 158 inside it. We are going to make each one explicitly, because the reasoning 159 will come back in Phases 2 and 3. 160 161 If you want the full conceptual model behind what you are about to write 162 — the render hierarchy, how react-leaflet bridges declarative React to 163 imperative Leaflet, and where the SSR boundary sits — read the companion 164 explanation alongside this step: 165 [[file:../explanation/explanation_step3_map_component.org][Anatomy of the Map Component]]. The tutorial below is the 166 /doing/; that document is the /understanding/. Either order works — some 167 learners prefer to read the explanation first, others prefer to build 168 first and then read the explanation to consolidate. 169 170 *** Decision 1: Where does the map live in the layout? 171 172 Your =__root.tsx= already renders =<Header />= and =<Footer />= around every 173 route. That means you have two options: 174 175 - *Full-bleed.* Replace =__root.tsx= so the map takes the entire viewport 176 (=height: 100vh=). Simpler CSS, but you throw away the Header you just 177 set up. 178 - *Keep the chrome.* Leave =__root.tsx= alone and size the map to the 179 /remaining/ viewport height — roughly =calc(100vh - <header+footer>)=. 180 181 We will keep the chrome. In Phase 2 the Header will host live connection 182 indicators (WebSocket "connected" / "reconnecting" badges) and in Phase 3 183 it will carry an SSE status light. Ripping out the Header now just means 184 rebuilding it later. 185 186 *** Decision 2: Where do you import Leaflet's CSS? 187 188 Leaflet ships its own stylesheet at =leaflet/dist/leaflet.css=. If you 189 forget to import it, tiles will still render — but zoom controls will look 190 broken and marker icons (later, in Step 7) will be invisible or misaligned. 191 This is the single most common Leaflet beginner trap. 192 193 Two reasonable places to put the import: 194 195 - Globally in =src/styles.css= or =__root.tsx=, loaded once for the whole 196 app. 197 - Co-located at the top of =Map.tsx=, loaded only when the map component 198 is used. 199 200 We will co-locate it in =Map.tsx=. It deletes cleanly with the component 201 and it does not leak Leaflet's styles into future routes that have nothing 202 to do with mapping. 203 204 *** Decision 3: Leaflet, TanStack Start, and SSR 205 206 TanStack Start renders routes on the server by default. Leaflet, on the 207 other hand, touches =window= at the top of its module — it assumes a 208 browser. Putting the two together has three possible outcomes: 209 210 1. Clean render on both server and client (lucky). 211 2. A hydration warning in the browser console. 212 3. A server-side =ReferenceError: window is not defined= in your terminal 213 before the page ever reaches the browser. 214 215 Our approach for this tutorial is *try the plain import first*. If the 216 error fires, that is the lesson — you have just learned, by direct 217 experience, that SSR and browser-only libraries need a boundary. The fix 218 (a client-only wrapper around the Leaflet import) will be written up as 219 a how-to; for now, if you see the error, note it and continue. 220 221 *** Write the component 222 223 Create =src/components/Map.tsx=: 224 225 #+begin_src js 226 // src/components/Map.tsx 227 import "leaflet/dist/leaflet.css"; 228 import { MapContainer, TileLayer } from "react-leaflet"; 229 230 const MAP_CENTER = [46.8, 8.2]; // Switzerland — dense OpenSky coverage 231 const MAP_ZOOM = 7; 232 233 export function Map() { 234 return ( 235 <MapContainer 236 center={MAP_CENTER} 237 zoom={MAP_ZOOM} 238 style={{ height: "calc(100vh - 8rem)", width: "100%" }} 239 > 240 <TileLayer 241 attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' 242 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" 243 /> 244 </MapContainer> 245 ); 246 } 247 #+end_src 248 249 The =8rem= in the height calc is an educated guess for the combined 250 Header + Footer height. If the map overlaps the footer or leaves a gap, 251 open devtools, measure the actual chrome height, and adjust. 252 253 *** Wire it into the index route 254 255 =src/routes/index.tsx= still holds the TanStack starter content (a hero 256 section, feature cards, a quick-start list). Replace the whole file body 257 with a minimal route that just renders the map. The Header and Footer 258 come from =__root.tsx=, so you do not repeat them here. 259 260 #+begin_src js 261 // src/routes/index.tsx 262 import { createFileRoute } from "@tanstack/react-router"; 263 import { Map } from "../components/Map"; 264 265 export const Route = createFileRoute("/")({ 266 component: Home, 267 }); 268 269 function Home() { 270 return <Map />; 271 } 272 #+end_src 273 274 *** Run it 275 276 #+begin_src bash 277 npm run dev 278 #+end_src 279 280 You should see your Header at the top, an interactive OpenStreetMap view 281 of Switzerland in the middle, and your Footer at the bottom. Click and 282 drag to pan. Scroll to zoom. Use the =+= / =-= controls. 283 284 If your terminal shows =ReferenceError: window is not defined=, the SSR 285 boundary we talked about in Decision 3 just fired. Read the stack trace, 286 notice that it points at Leaflet, and move on — we will solve it in a 287 dedicated how-to rather than derailing the tutorial here. 288 289 *** Why these three decisions matter later 290 291 - *Chrome stays* — Phase 2 will put a WebSocket status badge in the 292 Header. You are leaving room for it now. 293 - *CSS co-located* — in Step 7 (aircraft markers) and later in Phase 2 294 (mission markers) and Phase 3 (transit markers), each marker component 295 will import only what it needs. The map's styles travel with the map. 296 - *SSR awareness* — Phase 2's WebSocket client is also browser-only. When 297 you hit the same kind of boundary there, you will recognize the shape 298 of the problem instead of being surprised by it. 299 300 ** Step 4: Define the Aircraft Type 301 302 Create =src/types/aircraft.ts=: 303 304 #+begin_src js 305 // src/types/aircraft.ts 306 export interface Aircraft { 307 icao24: string; 308 callsign: string | null; 309 originCountry: string; 310 latitude: number | null; 311 longitude: number | null; 312 baroAltitude: number | null; 313 onGround: boolean; 314 velocity: number | null; 315 trueTrack: number | null; 316 verticalRate: number | null; 317 geoAltitude: number | null; 318 squawk: string | null; 319 } 320 #+end_src 321 322 You will notice that many fields are nullable. The OpenSky API returns =null= 323 when a sensor reading is unavailable. Your code must handle this. 324 325 ** Step 5: Create a Server Function for OpenSky 326 327 This is a key advantage of TanStack Start: you can write server-side code in the 328 same project. The server function runs on the server, so your API credentials 329 never reach the browser. 330 331 Create =src/server/opensky.ts=: 332 333 #+begin_src js 334 // src/server/opensky.ts 335 import { createServerFn } from "@tanstack/react-start"; 336 import type { Aircraft } from "../types/aircraft"; 337 338 const OPENSKY_URL = "https://opensky-network.org/api/states/all"; 339 340 // Bounding box: Switzerland 341 const BOUNDS = { 342 lamin: 45.8, 343 lamax: 47.8, 344 lomin: 5.9, 345 lomax: 10.5, 346 }; 347 348 function parseStateVector(state: unknown[]): Aircraft { 349 return { 350 icao24: state[0] as string, 351 callsign: (state[1] as string)?.trim() || null, 352 originCountry: state[2] as string, 353 latitude: state[6] as number | null, 354 longitude: state[5] as number | null, 355 baroAltitude: state[7] as number | null, 356 onGround: state[8] as boolean, 357 velocity: state[9] as number | null, 358 trueTrack: state[10] as number | null, 359 verticalRate: state[11] as number | null, 360 geoAltitude: state[13] as number | null, 361 squawk: state[14] as string | null, 362 }; 363 } 364 365 export const fetchAircraft = createServerFn({ method: "GET" }).handler( 366 async () => { 367 const params = new URLSearchParams({ 368 lamin: String(BOUNDS.lamin), 369 lamax: String(BOUNDS.lamax), 370 lomin: String(BOUNDS.lomin), 371 lomax: String(BOUNDS.lomax), 372 }); 373 374 const response = await fetch(`${OPENSKY_URL}?${params}`); 375 376 if (!response.ok) { 377 throw new Error(`OpenSky HTTP ${response.status}`); 378 } 379 380 const data = await response.json(); 381 return (data.states ?? []) 382 .map(parseStateVector) 383 .filter((a: Aircraft) => a.latitude !== null && a.longitude !== null); 384 } 385 ); 386 #+end_src 387 388 =createServerFn= defines a function that executes on the server but can be 389 called from client code as if it were a normal async function. TanStack Start 390 handles the network boundary transparently. This means the =fetch= to OpenSky 391 happens server-side — no CORS issues, no API keys in the browser. 392 393 ** Step 6: Build the Polling State Machine 394 395 This is where XState replaces the =useState= + =useEffect= + =setInterval= 396 pattern. Create =src/machines/aircraftPollingMachine.ts=: 397 398 #+begin_src js 399 // src/machines/aircraftPollingMachine.ts 400 import { setup, fromPromise, assign } from "xstate"; 401 import type { Aircraft } from "../types/aircraft"; 402 import { fetchAircraft } from "../server/opensky"; 403 404 const POLL_INTERVAL = 10_000; // 10 seconds 405 406 export const aircraftPollingMachine = setup({ 407 types: { 408 context: {} as { 409 aircraft: Aircraft[]; 410 error: string | null; 411 lastUpdated: Date | null; 412 }, 413 }, 414 actors: { 415 fetchAircraft: fromPromise(async () => { 416 return await fetchAircraft(); 417 }), 418 }, 419 }).createMachine({ 420 id: "aircraftPolling", 421 initial: "fetching", 422 context: { 423 aircraft: [], 424 error: null, 425 lastUpdated: null, 426 }, 427 states: { 428 fetching: { 429 invoke: { 430 src: "fetchAircraft", 431 onDone: { 432 target: "waiting", 433 actions: assign({ 434 aircraft: ({ event }) => event.output, 435 lastUpdated: () => new Date(), 436 error: () => null, 437 }), 438 }, 439 onError: { 440 target: "waiting", 441 actions: assign({ 442 error: ({ event }) => 443 event.error instanceof Error 444 ? event.error.message 445 : "Unknown error", 446 }), 447 }, 448 }, 449 }, 450 waiting: { 451 after: { 452 [POLL_INTERVAL]: "fetching", 453 }, 454 }, 455 }, 456 }); 457 #+end_src 458 459 Compare this to the =useEffect= + =setInterval= version. The state machine 460 makes the polling lifecycle explicit: 461 462 1. =fetching= — an API call is in flight. Only one fetch at a time. No 463 overlapping requests. 464 2. =waiting= — the response arrived (success or error). Wait 10 seconds, then 465 go back to =fetching=. 466 467 There is no cleanup function to forget. There is no =useRef= to hold the 468 interval ID. The machine handles it. 469 470 Notice the =after= property in the =waiting= state. XState's delayed 471 transitions replace =setTimeout= / =setInterval=. When the machine enters 472 =waiting=, it starts a 10-second timer. When the timer fires, it transitions 473 back to =fetching=. If the component unmounts, the machine stops, and the timer 474 is cancelled automatically. 475 476 ** Step 7: Render Aircraft Markers on the Map 477 478 Create =src/components/AircraftMarker.tsx=: 479 480 #+begin_src js 481 // src/components/AircraftMarker.tsx 482 import { Marker, Popup } from "react-leaflet"; 483 import L from "leaflet"; 484 import type { Aircraft } from "../types/aircraft"; 485 486 const aircraftIcon = L.divIcon({ 487 html: "✈", 488 className: "aircraft-icon", 489 iconSize: [20, 20], 490 iconAnchor: [10, 10], 491 }); 492 493 interface AircraftMarkerProps { 494 aircraft: Aircraft; 495 onSelect: (aircraft: Aircraft) => void; 496 } 497 498 export function AircraftMarker({ aircraft, onSelect }: AircraftMarkerProps) { 499 if (aircraft.latitude === null || aircraft.longitude === null) { 500 return null; 501 } 502 503 return ( 504 <Marker 505 position={[aircraft.latitude, aircraft.longitude]} 506 icon={aircraftIcon} 507 eventHandlers={{ 508 click: () => onSelect(aircraft), 509 }} 510 > 511 <Popup> 512 <strong>{aircraft.callsign ?? aircraft.icao24}</strong> 513 <br /> 514 Altitude: {aircraft.baroAltitude?.toFixed(0) ?? "N/A"} m 515 <br /> 516 Speed: {aircraft.velocity?.toFixed(0) ?? "N/A"} m/s 517 </Popup> 518 </Marker> 519 ); 520 } 521 #+end_src 522 523 Update =src/components/Map.tsx= to include the markers and the state machine: 524 525 #+begin_src js 526 // src/components/Map.tsx 527 import { useState } from "react"; 528 import { MapContainer, TileLayer } from "react-leaflet"; 529 import { useMachine } from "@xstate/react"; 530 import { aircraftPollingMachine } from "../machines/aircraftPollingMachine"; 531 import { AircraftMarker } from "./AircraftMarker"; 532 import type { Aircraft } from "../types/aircraft"; 533 534 const MAP_CENTER: [number, number] = [46.8, 8.2]; 535 const MAP_ZOOM = 7; 536 537 export function Map() { 538 const [snapshot] = useMachine(aircraftPollingMachine); 539 const { aircraft, error, lastUpdated } = snapshot.context; 540 const [selected, setSelected] = useState<Aircraft | null>(null); 541 542 return ( 543 <div style={{ display: "flex", height: "100vh" }}> 544 <MapContainer center={MAP_CENTER} zoom={MAP_ZOOM} style={{ flex: 1 }}> 545 <TileLayer 546 attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' 547 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" 548 /> 549 {aircraft.map((a) => ( 550 <AircraftMarker 551 key={a.icao24} 552 aircraft={a} 553 onSelect={setSelected} 554 /> 555 ))} 556 </MapContainer> 557 558 <aside style={{ width: 300, padding: 16, overflowY: "auto" }}> 559 <h2>Mission Control</h2> 560 <p>{aircraft.length} aircraft tracked</p> 561 <p> 562 State: <code>{snapshot.value}</code> 563 </p> 564 {lastUpdated && ( 565 <p>Last update: {lastUpdated.toLocaleTimeString()}</p> 566 )} 567 {error && <p style={{ color: "red" }}>Error: {error}</p>} 568 569 {selected && ( 570 <div> 571 <h3>{selected.callsign ?? selected.icao24}</h3> 572 <table> 573 <tbody> 574 <tr><td>Country</td><td>{selected.originCountry}</td></tr> 575 <tr><td>Altitude</td><td>{selected.baroAltitude?.toFixed(0) ?? "N/A"} m</td></tr> 576 <tr><td>Speed</td><td>{selected.velocity?.toFixed(0) ?? "N/A"} m/s</td></tr> 577 <tr><td>Heading</td><td>{selected.trueTrack?.toFixed(0) ?? "N/A"}°</td></tr> 578 <tr><td>Vertical rate</td><td>{selected.verticalRate?.toFixed(1) ?? "N/A"} m/s</td></tr> 579 <tr><td>On ground</td><td>{selected.onGround ? "Yes" : "No"}</td></tr> 580 <tr><td>Squawk</td><td>{selected.squawk ?? "N/A"}</td></tr> 581 </tbody> 582 </table> 583 </div> 584 )} 585 </aside> 586 </div> 587 ); 588 } 589 #+end_src 590 591 Add a small CSS rule for the aircraft icon. Create =src/aircraft.css=: 592 593 #+begin_src css 594 /* src/aircraft.css */ 595 .aircraft-icon { 596 font-size: 20px; 597 line-height: 20px; 598 text-align: center; 599 color: #1a73e8; 600 background: none; 601 border: none; 602 } 603 #+end_src 604 605 Import it at the top of =src/components/Map.tsx=: 606 607 #+begin_src js 608 import "../aircraft.css"; 609 #+end_src 610 611 Run =npm run dev= and open the browser. You will see aircraft icons appearing 612 over Switzerland, updating every 10 seconds. Clicking an aircraft shows its 613 telemetry in the sidebar. 614 615 Notice =State: fetching= flashes briefly during each poll, then returns to 616 =waiting=. This is the state machine at work — you can see exactly what the 617 system is doing at any moment. 618 619 ** Phase 1 Checkpoint 620 621 You now have a working dashboard with: 622 - A live map showing 50-200 aircraft 623 - A sidebar with asset count, machine state, last update time, and error state 624 - A telemetry panel for the selected aircraft 625 - Data refreshing every 10 seconds via an XState polling machine 626 - API calls proxied through a TanStack Start server function 627 628 The polling pattern works but has limitations: you are always 10 seconds behind, 629 you get the full state every time (not just changes), and you are using API 630 credits on every request. Phase 2 introduces WebSocket streaming which solves 631 all three problems. 632 633 * Phase 2: Simulated Mission Assets over WebSocket 634 635 In this phase you will build a WebSocket server on your VPS that simulates 636 mission assets (drones, vehicles, sensors) and stream their positions to the 637 dashboard in real time. You will model the WebSocket connection lifecycle as an 638 XState machine. 639 640 ** Step 8: Build the WebSocket Server 641 642 On your VPS, create a new project: 643 644 #+begin_src bash 645 mkdir mission-ws-server && cd mission-ws-server 646 npm init -y 647 npm install ws 648 #+end_src 649 650 Create =server.js=: 651 652 #+begin_src javascript 653 // server.js 654 const { WebSocketServer } = require("ws"); 655 656 const PORT = 8080; 657 const NUM_ASSETS = 50; 658 659 // Generate initial positions around a base location 660 function createAssets() { 661 const baseLat = 46.8; 662 const baseLng = 8.2; 663 const types = ["drone", "vehicle", "sensor"]; 664 const statuses = ["active", "idle", "alert"]; 665 666 return Array.from({ length: NUM_ASSETS }, (_, i) => ({ 667 id: `ASSET-${String(i + 1).padStart(3, "0")}`, 668 type: types[i % types.length], 669 status: statuses[0], 670 latitude: baseLat + (Math.random() - 0.5) * 2, 671 longitude: baseLng + (Math.random() - 0.5) * 4, 672 altitude: Math.random() * 5000, 673 speed: Math.random() * 100, 674 heading: Math.random() * 360, 675 battery: 80 + Math.random() * 20, 676 lastUpdate: Date.now(), 677 })); 678 } 679 680 function moveAsset(asset) { 681 const headingRad = (asset.heading * Math.PI) / 180; 682 const distance = (asset.speed / 3600) * 0.001; // rough degrees per tick 683 asset.latitude += Math.cos(headingRad) * distance; 684 asset.longitude += Math.sin(headingRad) * distance; 685 asset.heading += (Math.random() - 0.5) * 10; 686 asset.speed = Math.max(0, asset.speed + (Math.random() - 0.5) * 5); 687 asset.altitude = Math.max(0, asset.altitude + (Math.random() - 0.5) * 50); 688 asset.battery = Math.max(0, asset.battery - Math.random() * 0.1); 689 asset.lastUpdate = Date.now(); 690 691 // Randomly trigger alerts 692 if (Math.random() < 0.005) { 693 asset.status = "alert"; 694 } else if (asset.status === "alert" && Math.random() < 0.1) { 695 asset.status = "active"; 696 } 697 698 return asset; 699 } 700 701 const assets = createAssets(); 702 const alerts = []; 703 704 const wss = new WebSocketServer({ port: PORT }); 705 706 wss.on("connection", (ws) => { 707 console.log("Client connected"); 708 709 // Send full state on connection 710 ws.send(JSON.stringify({ type: "snapshot", assets, alerts })); 711 712 ws.on("close", () => console.log("Client disconnected")); 713 }); 714 715 // Broadcast updates every second 716 setInterval(() => { 717 assets.forEach(moveAsset); 718 719 // Generate alert if any asset is in alert status 720 const alertingAssets = assets.filter((a) => a.status === "alert"); 721 alertingAssets.forEach((a) => { 722 if (!alerts.find((al) => al.assetId === a.id && al.active)) { 723 const alert = { 724 id: `ALERT-${Date.now()}`, 725 assetId: a.id, 726 message: `${a.type} ${a.id} triggered alert — battery: ${a.battery.toFixed(0)}%`, 727 severity: a.battery < 20 ? "critical" : "warning", 728 timestamp: Date.now(), 729 active: true, 730 }; 731 alerts.push(alert); 732 } 733 }); 734 735 // Keep only last 50 alerts 736 while (alerts.length > 50) alerts.shift(); 737 738 const message = JSON.stringify({ 739 type: "update", 740 assets, 741 alerts: alerts.filter((a) => a.active), 742 }); 743 744 wss.clients.forEach((client) => { 745 if (client.readyState === 1) { 746 client.send(message); 747 } 748 }); 749 }, 1000); 750 751 console.log(`WebSocket server running on ws://localhost:${PORT}`); 752 #+end_src 753 754 Start the server: 755 756 #+begin_src bash 757 node server.js 758 #+end_src 759 760 #+begin_example 761 WebSocket server running on ws://localhost:8080 762 #+end_example 763 764 ** Step 9: Define Mission Types 765 766 Back in your TanStack Start project, create =src/types/mission.ts=: 767 768 #+begin_src js 769 // src/types/mission.ts 770 export interface MissionAsset { 771 id: string; 772 type: "drone" | "vehicle" | "sensor"; 773 status: "active" | "idle" | "alert"; 774 latitude: number; 775 longitude: number; 776 altitude: number; 777 speed: number; 778 heading: number; 779 battery: number; 780 lastUpdate: number; 781 } 782 783 export interface MissionAlert { 784 id: string; 785 assetId: string; 786 message: string; 787 severity: "warning" | "critical"; 788 timestamp: number; 789 active: boolean; 790 } 791 792 export interface MissionMessage { 793 type: "snapshot" | "update"; 794 assets: MissionAsset[]; 795 alerts: MissionAlert[]; 796 } 797 #+end_src 798 799 ** Step 10: Build the WebSocket State Machine 800 801 This is where XState really shines. A WebSocket connection has a clear 802 lifecycle — connecting, connected, disconnected, reconnecting — that maps 803 perfectly to a state machine. 804 805 Create =src/machines/missionWebSocketMachine.ts=: 806 807 #+begin_src js 808 // src/machines/missionWebSocketMachine.ts 809 import { setup, fromCallback, assign } from "xstate"; 810 import type { MissionAsset, MissionAlert, MissionMessage } from "../types/mission"; 811 812 const WS_URL = "ws://YOUR_VPS_IP:8080"; // Replace with your VPS address 813 const RECONNECT_DELAY = 3_000; 814 815 type WebSocketEvent = 816 | { type: "CONNECTED" } 817 | { type: "MESSAGE"; data: MissionMessage } 818 | { type: "DISCONNECTED" }; 819 820 export const missionWebSocketMachine = setup({ 821 types: { 822 context: {} as { 823 assets: MissionAsset[]; 824 alerts: MissionAlert[]; 825 connected: boolean; 826 }, 827 events: {} as WebSocketEvent, 828 }, 829 actors: { 830 webSocket: fromCallback<WebSocketEvent>(({ sendBack }) => { 831 const ws = new WebSocket(WS_URL); 832 833 ws.onopen = () => sendBack({ type: "CONNECTED" }); 834 835 ws.onmessage = (event) => { 836 const message: MissionMessage = JSON.parse(event.data); 837 sendBack({ type: "MESSAGE", data: message }); 838 }; 839 840 ws.onclose = () => sendBack({ type: "DISCONNECTED" }); 841 ws.onerror = () => ws.close(); 842 843 return () => ws.close(); 844 }), 845 }, 846 }).createMachine({ 847 id: "missionWebSocket", 848 initial: "connecting", 849 context: { 850 assets: [], 851 alerts: [], 852 connected: false, 853 }, 854 states: { 855 connecting: { 856 invoke: { 857 src: "webSocket", 858 }, 859 on: { 860 CONNECTED: { 861 actions: assign({ connected: () => true }), 862 }, 863 MESSAGE: { 864 actions: assign({ 865 assets: ({ event }) => event.data.assets, 866 alerts: ({ event }) => event.data.alerts, 867 }), 868 }, 869 DISCONNECTED: "reconnecting", 870 }, 871 }, 872 reconnecting: { 873 entry: assign({ connected: () => false }), 874 after: { 875 [RECONNECT_DELAY]: "connecting", 876 }, 877 }, 878 }, 879 }); 880 #+end_src 881 882 Compare this to the =useEffect= + =useRef= + =useCallback= version. The custom 883 hook had reconnection logic scattered across =onclose=, =setTimeout=, and a 884 =useCallback=. The machine makes every state transition visible: 885 886 - =connecting= → invokes the WebSocket actor. Events flow in via =sendBack=. 887 - If a =DISCONNECTED= event arrives → transition to =reconnecting=. 888 - =reconnecting= → wait 3 seconds, then go back to =connecting=. 889 890 The =fromCallback= actor is perfect for WebSocket: it is a long-running process 891 that sends events back to the parent machine. The cleanup function (=return () => 892 ws.close()=) runs when the machine leaves the =connecting= state or stops 893 entirely. 894 895 ** Step 11: Add Mission Assets to the Map 896 897 Create =src/components/MissionMarker.tsx=: 898 899 #+begin_src js 900 // src/components/MissionMarker.tsx 901 import { Marker, Popup } from "react-leaflet"; 902 import L from "leaflet"; 903 import type { MissionAsset } from "../types/mission"; 904 905 const icons: Record<string, L.DivIcon> = { 906 drone: L.divIcon({ html: "🛸", className: "mission-icon", iconSize: [20, 20], iconAnchor: [10, 10] }), 907 vehicle: L.divIcon({ html: "🚙", className: "mission-icon", iconSize: [20, 20], iconAnchor: [10, 10] }), 908 sensor: L.divIcon({ html: "📡", className: "mission-icon", iconSize: [20, 20], iconAnchor: [10, 10] }), 909 }; 910 911 interface MissionMarkerProps { 912 asset: MissionAsset; 913 onSelect: (asset: MissionAsset) => void; 914 } 915 916 export function MissionMarker({ asset, onSelect }: MissionMarkerProps) { 917 return ( 918 <Marker 919 position={[asset.latitude, asset.longitude]} 920 icon={icons[asset.type]} 921 eventHandlers={{ click: () => onSelect(asset) }} 922 > 923 <Popup> 924 <strong>{asset.id}</strong> ({asset.type}) 925 <br /> 926 Status: {asset.status} 927 <br /> 928 Battery: {asset.battery.toFixed(0)}% 929 </Popup> 930 </Marker> 931 ); 932 } 933 #+end_src 934 935 ** Step 12: Add the Alerts Sidebar 936 937 Create =src/components/AlertsSidebar.tsx=: 938 939 #+begin_src js 940 // src/components/AlertsSidebar.tsx 941 import type { MissionAlert } from "../types/mission"; 942 943 interface AlertsSidebarProps { 944 alerts: MissionAlert[]; 945 } 946 947 export function AlertsSidebar({ alerts }: AlertsSidebarProps) { 948 return ( 949 <div> 950 <h3>Alerts ({alerts.length})</h3> 951 {alerts.length === 0 && <p>No active alerts</p>} 952 <ul style={{ listStyle: "none", padding: 0 }}> 953 {alerts.map((alert) => ( 954 <li 955 key={alert.id} 956 style={{ 957 padding: 8, 958 marginBottom: 4, 959 background: alert.severity === "critical" ? "#fde2e2" : "#fff3cd", 960 borderLeft: `4px solid ${alert.severity === "critical" ? "#dc3545" : "#ffc107"}`, 961 }} 962 > 963 <strong>{alert.severity.toUpperCase()}</strong> 964 <br /> 965 {alert.message} 966 <br /> 967 <small>{new Date(alert.timestamp).toLocaleTimeString()}</small> 968 </li> 969 ))} 970 </ul> 971 </div> 972 ); 973 } 974 #+end_src 975 976 Update =src/components/Map.tsx= to include mission assets alongside aircraft. 977 Add the import and =useMachine= call for =missionWebSocketMachine=, then render 978 =MissionMarker= components the same way you rendered =AircraftMarker=. 979 980 ** Phase 2 Checkpoint 981 982 You now have two data sources feeding the same dashboard: 983 - Aircraft from OpenSky (polling every 10s via XState =after= delay) 984 - Mission assets from your WebSocket server (streaming every 1s via XState 985 =fromCallback=) 986 987 The difference is visible: mission assets move smoothly, aircraft jump. This is 988 the fundamental trade-off between polling and streaming. 989 990 Both machines manage their own lifecycle independently. If the WebSocket 991 disconnects, it reconnects without affecting aircraft polling. If OpenSky returns 992 an error, the machine captures it and keeps polling. Each machine is a 993 self-contained unit. 994 995 ** Optional upgrade: self-hosted map tiles 996 997 Your VPS is now running a WebSocket server behind Caddy with TLS. This is a 998 good moment to consider self-hosting your own OSM tile server on the same VPS — 999 eliminating the dependency on OpenStreetMap's public tile servers and unlocking 1000 dark map themes via CSS filters. 1001 1002 This is an optional upgrade. The dashboard works fine with the default OSM 1003 tiles. But if you want full control over your map layer, see the 1004 [[file:../decisions/adr_002_map_tile_provider.org::*Self-hosted tile server (future upgrade)][ADR-002 upgrade path]] for the rationale and requirements, and the how-to guide 1005 (coming soon) for step-by-step setup. 1006 1007 * Phase 3: Transit Vehicles via Server-Sent Events 1008 1009 In this phase you will add Boston transit vehicles from the MBTA API using 1010 Server-Sent Events — a third real-time data transport pattern. You will model 1011 it with yet another XState machine, this time using =fromCallback= for the 1012 =EventSource= subscription. 1013 1014 ** Step 13: Define Transit Types 1015 1016 Create =src/types/transit.ts=: 1017 1018 #+begin_src js 1019 // src/types/transit.ts 1020 export interface TransitVehicle { 1021 id: string; 1022 label: string; 1023 latitude: number; 1024 longitude: number; 1025 bearing: number | null; 1026 speed: number | null; 1027 currentStatus: string; 1028 routeId: string; 1029 updatedAt: string; 1030 } 1031 #+end_src 1032 1033 ** Step 14: Build the SSE State Machine 1034 1035 Create =src/machines/transitSSEMachine.ts=: 1036 1037 #+begin_src js 1038 // src/machines/transitSSEMachine.ts 1039 import { setup, fromCallback, assign } from "xstate"; 1040 import type { TransitVehicle } from "../types/transit"; 1041 1042 const MBTA_API_KEY = "YOUR_MBTA_API_KEY"; // Replace with your key 1043 const SSE_URL = `https://api-v3.mbta.com/vehicles?filter[route_type]=3&api_key=${MBTA_API_KEY}`; 1044 1045 function parseVehicle(resource: any): TransitVehicle { 1046 return { 1047 id: resource.id, 1048 label: resource.attributes.label, 1049 latitude: resource.attributes.latitude, 1050 longitude: resource.attributes.longitude, 1051 bearing: resource.attributes.bearing, 1052 speed: resource.attributes.speed, 1053 currentStatus: resource.attributes.current_status, 1054 routeId: resource.relationships?.route?.data?.id ?? "unknown", 1055 updatedAt: resource.attributes.updated_at, 1056 }; 1057 } 1058 1059 type SSEEvent = 1060 | { type: "CONNECTED" } 1061 | { type: "RESET"; data: any[] } 1062 | { type: "UPDATE"; data: any } 1063 | { type: "ADD"; data: any } 1064 | { type: "REMOVE"; data: any } 1065 | { type: "ERROR" }; 1066 1067 export const transitSSEMachine = setup({ 1068 types: { 1069 context: {} as { 1070 vehicles: TransitVehicle[]; 1071 connected: boolean; 1072 }, 1073 events: {} as SSEEvent, 1074 }, 1075 actors: { 1076 eventSource: fromCallback<SSEEvent>(({ sendBack }) => { 1077 const source = new EventSource(SSE_URL); 1078 1079 source.onopen = () => sendBack({ type: "CONNECTED" }); 1080 1081 source.addEventListener("reset", (event: MessageEvent) => { 1082 sendBack({ type: "RESET", data: JSON.parse(event.data) }); 1083 }); 1084 1085 source.addEventListener("update", (event: MessageEvent) => { 1086 sendBack({ type: "UPDATE", data: JSON.parse(event.data) }); 1087 }); 1088 1089 source.addEventListener("add", (event: MessageEvent) => { 1090 sendBack({ type: "ADD", data: JSON.parse(event.data) }); 1091 }); 1092 1093 source.addEventListener("remove", (event: MessageEvent) => { 1094 sendBack({ type: "REMOVE", data: JSON.parse(event.data) }); 1095 }); 1096 1097 source.onerror = () => sendBack({ type: "ERROR" }); 1098 1099 return () => source.close(); 1100 }), 1101 }, 1102 }).createMachine({ 1103 id: "transitSSE", 1104 initial: "streaming", 1105 context: { 1106 vehicles: [], 1107 connected: false, 1108 }, 1109 states: { 1110 streaming: { 1111 invoke: { 1112 src: "eventSource", 1113 }, 1114 on: { 1115 CONNECTED: { 1116 actions: assign({ connected: () => true }), 1117 }, 1118 RESET: { 1119 actions: assign({ 1120 vehicles: ({ event }) => event.data.map(parseVehicle), 1121 }), 1122 }, 1123 UPDATE: { 1124 actions: assign({ 1125 vehicles: ({ context, event }) => { 1126 const updated = parseVehicle(event.data); 1127 return context.vehicles.map((v) => 1128 v.id === updated.id ? updated : v 1129 ); 1130 }, 1131 }), 1132 }, 1133 ADD: { 1134 actions: assign({ 1135 vehicles: ({ context, event }) => [ 1136 ...context.vehicles, 1137 parseVehicle(event.data), 1138 ], 1139 }), 1140 }, 1141 REMOVE: { 1142 actions: assign({ 1143 vehicles: ({ context, event }) => 1144 context.vehicles.filter((v) => v.id !== event.data.id), 1145 }), 1146 }, 1147 ERROR: { 1148 actions: assign({ connected: () => false }), 1149 // EventSource reconnects automatically — stay in streaming state 1150 }, 1151 }, 1152 }, 1153 }, 1154 }); 1155 #+end_src 1156 1157 Notice the difference from the WebSocket machine: there is no =reconnecting= 1158 state. =EventSource= reconnects automatically — it is built into the SSE 1159 specification. The machine stays in the =streaming= state even on error, because 1160 the browser will re-establish the connection without your help. 1161 1162 Also notice how SSE handles different event types (=reset=, =update=, =add=, 1163 =remove=). Unlike the WebSocket server which sends you the full state each time, 1164 MBTA's SSE stream sends granular events. This is more bandwidth-efficient but 1165 requires you to manage state incrementally — XState's =assign= actions make 1166 each update explicit. 1167 1168 ** Step 15: Add Transit Markers 1169 1170 Create =src/components/TransitMarker.tsx= following the same pattern as 1171 =AircraftMarker= and =MissionMarker=. Use a bus icon (🚌) and display route, 1172 status, and speed in the popup. 1173 1174 ** Step 16: Create the Dashboard Store 1175 1176 Right now, each machine holds its own data in its context, and components read 1177 from three separate snapshots. This works, but it creates a problem: the sidebar 1178 needs data from all three machines, the telemetry panel needs a "selected asset" 1179 that could be any type, and the alert queue spans sources. 1180 1181 This is where =@xstate/store= comes in. Instead of each component reaching into 1182 three machine snapshots, we create a single shared store that all machines push 1183 data into. 1184 1185 Create =src/stores/dashboard.store.ts=: 1186 1187 #+begin_src javascript 1188 // src/stores/dashboard.store.ts 1189 import { createStore } from "@xstate/store"; 1190 import type { Aircraft } from "../types/aircraft"; 1191 import type { MissionAsset, MissionAlert } from "../types/mission"; 1192 import type { TransitVehicle } from "../types/transit"; 1193 1194 export type Asset = 1195 | (Aircraft & { source: "opensky" }) 1196 | (MissionAsset & { source: "mission" }) 1197 | (TransitVehicle & { source: "transit" }); 1198 1199 export const dashboardStore = createStore({ 1200 context: { 1201 aircraft: [] as Aircraft[], 1202 missionAssets: [] as MissionAsset[], 1203 transitVehicles: [] as TransitVehicle[], 1204 alerts: [] as MissionAlert[], 1205 selectedAssetId: null as string | null, 1206 }, 1207 on: { 1208 "aircraft.updated": (ctx, event: { aircraft: Aircraft[] }) => ({ 1209 ...ctx, 1210 aircraft: event.aircraft, 1211 }), 1212 "mission.updated": ( 1213 ctx, 1214 event: { assets: MissionAsset[]; alerts: MissionAlert[] }, 1215 ) => ({ 1216 ...ctx, 1217 missionAssets: event.assets, 1218 alerts: event.alerts, 1219 }), 1220 "transit.reset": (ctx, event: { vehicles: TransitVehicle[] }) => ({ 1221 ...ctx, 1222 transitVehicles: event.vehicles, 1223 }), 1224 "transit.updated": (ctx, event: { vehicle: TransitVehicle }) => ({ 1225 ...ctx, 1226 transitVehicles: ctx.transitVehicles.map((v) => 1227 v.id === event.vehicle.id ? event.vehicle : v, 1228 ), 1229 }), 1230 "asset.selected": (ctx, event: { id: string | null }) => ({ 1231 ...ctx, 1232 selectedAssetId: event.id, 1233 }), 1234 }, 1235 }); 1236 #+end_src 1237 1238 Notice the pattern: the store's event names use =source.verb= naming 1239 (=aircraft.updated=, =transit.reset=). Each transition is a pure function — same 1240 inputs, same outputs, easy to test. 1241 1242 Now the machines push data into the store instead of only holding it in their own 1243 context. In each machine's =onDone= or event handler, add a call to the store: 1244 1245 #+begin_src javascript 1246 import { dashboardStore } from "../stores/dashboard.store"; 1247 1248 // In the polling machine's onDone action: 1249 actions: [ 1250 assign({ aircraft: ({ event }) => event.output }), 1251 ({ event }) => dashboardStore.send({ 1252 type: "aircraft.updated", 1253 aircraft: event.output, 1254 }), 1255 ], 1256 #+end_src 1257 1258 The same pattern applies to the WebSocket and SSE machines — each pushes its 1259 data into the shared store when it receives updates. 1260 1261 ** Step 17: Bring It All Together 1262 1263 Update your =Map.tsx= to read from the dashboard store using =useSelector=: 1264 1265 #+begin_src javascript 1266 import { useSelector } from "@xstate/store/react"; 1267 import { dashboardStore } from "../stores/dashboard.store"; 1268 1269 // In the component: 1270 const aircraft = useSelector(dashboardStore, (s) => s.context.aircraft); 1271 const missionAssets = useSelector(dashboardStore, (s) => s.context.missionAssets); 1272 const transitVehicles = useSelector(dashboardStore, (s) => s.context.transitVehicles); 1273 const alerts = useSelector(dashboardStore, (s) => s.context.alerts); 1274 const selectedAssetId = useSelector(dashboardStore, (s) => s.context.selectedAssetId); 1275 #+end_src 1276 1277 The machines still run independently via =useMachine= (they manage connection 1278 lifecycle), but the UI reads from the store. Each =useSelector= call only 1279 re-renders the component when that specific slice of state changes. 1280 1281 The sidebar should show: 1282 1283 - Total assets tracked (aircraft + mission + transit) 1284 - Connection status for WebSocket and SSE (from machine context) 1285 - Machine states (=fetching= / =waiting= for polling, =connecting= / 1286 =reconnecting= for WebSocket) 1287 - Last poll time for OpenSky 1288 - Alerts from the mission server 1289 - Telemetry panel for whichever asset is selected 1290 1291 ** Phase 3 Checkpoint 1292 1293 You now have a dashboard consuming three real-time data sources using three 1294 different transport patterns. The map shows aircraft, mission assets, and 1295 transit vehicles simultaneously. 1296 1297 Each data source is managed by its own XState machine for connection lifecycle. 1298 The machines push data into a shared =@xstate/store= which provides a single 1299 source of truth for all UI components. React components read from the store via 1300 =useSelector= — they do not need to know which machine or transport pattern 1301 produced the data. 1302 1303 * What You Built 1304 1305 #+begin_example 1306 ┌──────────────────────────────────────────────────────┐ 1307 │ Mission Control Dashboard │ 1308 ├────────────────────────────────────┬─────────────────┤ 1309 │ │ Status │ 1310 │ Interactive Map │ ● WS Connected │ 1311 │ │ ● SSE Connected │ 1312 │ ✈ Aircraft (polling, 10s) │ ↻ Poll: 12:34 │ 1313 │ 🛸 Drones (WebSocket, 1s) │ │ 1314 │ 🚌 Buses (SSE, streaming) │ Alerts (3) │ 1315 │ │ ⚠ ASSET-012... │ 1316 │ │ 🔴 ASSET-007... │ 1317 │ │ │ 1318 │ │ Telemetry │ 1319 │ │ Speed: 42 m/s │ 1320 │ │ Alt: 1200 m │ 1321 │ │ Hdg: 270° │ 1322 └────────────────────────────────────┴─────────────────┘ 1323 #+end_example 1324 1325 You learned: 1326 1327 1. *TanStack Start* — file-based routing, server functions that proxy external 1328 APIs (keeping credentials off the client), SSR-ready React 1329 2. *XState machines for connection lifecycle* — =fromPromise= + =after= for 1330 polling, =fromCallback= for WebSocket and SSE long-lived connections 1331 3. *@xstate/store for shared state* — event-driven store that unifies data from 1332 all three machines into a single source of truth 1333 4. *Two-layer state architecture* — machines own connection logic (reconnection, 1334 error recovery), the store owns application data (combined assets, selection, 1335 alerts) 1336 5. *Polling vs WebSocket vs SSE* — simple/delayed vs bidirectional/fast vs 1337 server-push/auto-reconnect 1338 1339 * Next Steps 1340 1341 - [[file:../explanation/explanation_realtime_data_patterns.org][Explanation: Real-Time Data Patterns]] — understand the trade-offs deeply 1342 - [[file:../howto/howto_connect_opensky.org][How-to: Connect to OpenSky Network]] — authentication, rate limits, bounding boxes 1343 - [[file:../howto/howto_websocket_server.org][How-to: Build a WebSocket Server]] — production hardening, scaling 1344 - [[file:../howto/howto_consume_sse_react.org][How-to: Consume SSE in React with XState]] — edge cases, error handling 1345 - [[file:../reference/ref_api_contracts.org][Reference: API Contracts]] — exact field shapes for all three APIs 1346 - [[file:../decisions/adr_002_map_tile_provider.org][ADR-002: Map Tile Provider]] — self-host OSM tiles on your VPS for full 1347 control and dark map themes