Developer Guide
This page documents how the Vue client talks to the backend over its one WebSocket connection — how that connection comes to exist, and how telemetry subscription is layered on top of it for components that need live sensor data. For how a reading gets from a physical sensor to the point where this connection receives it, see Contracts Service Architecture and Socket Service Architecture; this page starts where those leave off — the moment a telemetry event lands in the browser.
File: frontend/src/services/socket.ts
The whole app shares a single Socket.IO client instance, created once at module load:
export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(URL, {
autoConnect: false,
transports: ['websocket'],
withCredentials: true,
})autoConnect: false means nothing happens at load time — the connection is opened deliberately, from App.vue, once the user is actually authenticated:
// The socket handshake requires the auth cookie, so only connect once the user
// is authenticated; drop the connection on logout. immediate covers the initial
// state (and the false→true flip when hydrate() resolves from the cookie).
watch(
() => authStore.isAuthenticated,
(authed) => (authed ? socket.connect() : socket.disconnect()),
{ immediate: true },
)withCredentials: true is what makes this work: it’s the client-side half of the httpOnly-cookie handshake documented in Socket Service — the browser attaches the authentication_token cookie to the WebSocket upgrade request automatically, client code never reads or holds the token itself. Connecting only after isAuthenticated flips true means the very first handshake already carries a cookie worth authenticating; there’s no unauthenticated connection attempt to fail and retry.
socket.ts also owns the one piece of WebSocket-driven state that isn’t telemetry: incoming notification events are appended straight to a shared reactive socketState (message, type, timestamp, a capped 100-item list, an unread counter) that any component can read directly — notifications don’t need a bucketed, opt-in subscription model the way telemetry does, since there’s only ever one feed a connected client cares about.
File: frontend/src/stores/sensorData.ts
Multiple components routinely want the same building’s same metric at once — the 3D model and the room list both read live temperature, for instance. Rather than each opening its own REST fetch, its own subscribe_building call, and its own telemetry handler, they all acquire a reference to one shared bucket, keyed by (type, buildingId):
graph TD
APP["App.vue\nconnects socket.ts on login"] --> SOCKET["services/socket.ts\none shared Socket.IO client"]
SOCKET --> STORE["stores/sensorData.ts\nSensorDataStore — buckets by (type, buildingId)"]
STORE --> HOOK["composables/useBuildingSensor.ts\nacquire on mount, release on cleanup"]
HOOK --> C1["3D model view"]
HOOK --> C2["Room list"]
HOOK --> C3["...any other component"]
SOCKET -->|"telemetry event"| STOREfunction acquire(buildingId: string, type: SensorType): SensorBucket {
const key = bucketKey(type, buildingId)
let bucket = buckets.get(key)
if (!bucket) {
const firstForBuilding = !hasBucketForBuilding(buildingId)
bucket = { data: shallowRef([]), isLoading: ref(false), error: ref(null), refCount: 0, abort: null }
buckets.set(key, bucket)
if (firstForBuilding) socket.emit('subscribe_building', buildingId)
void fetchBucket(type, buildingId, bucket)
}
bucket.refCount++
return bucket
}
function release(buildingId: string, type: SensorType) {
const key = bucketKey(type, buildingId)
const bucket = buckets.get(key)
if (bucket && --bucket.refCount <= 0) {
bucket.abort?.abort()
buckets.delete(key)
}
if (!hasBucketForBuilding(buildingId)) socket.emit('unsubscribe_building', buildingId)
}Only the first caller for a building triggers subscribe_building; only the last release for that building triggers unsubscribe_building — the socket room’s lifetime is derived entirely from whether any bucket still references that building, with no separate counter to keep in sync. acquire also kicks off a REST fetch (GET /sensor/:type/entireBuilding?building=...) for the initial snapshot, so a component sees the last known values immediately rather than waiting for the next telemetry tick; a second acquire for the same key doesn’t refetch, it just bumps refCount and returns the existing bucket.
One global handler dispatches every incoming event to its bucket by an O(1) map lookup — there’s no per-component listener to accumulate as more components mount:
function onTelemetry(rawEvent: unknown) {
const event = rawEvent as ApiDataPoint & { type?: SensorType; buildingId?: string }
const key = bucketKey(event.type as SensorType, event.buildingId as string)
const bucket = buckets.get(key)
if (!bucket) return
const arr = bucket.data.value
const idx = arr.findIndex((d) => d.roomId === event.roomId)
if (idx >= 0) arr[idx] = { ...arr[idx], ...event }
else arr.push(event)
dirty.add(key)
scheduleFlush()
}Every touched bucket is marked dirty and flushed together on the next animation frame, rather than triggering a reactive update per event:
function scheduleFlush() {
if (rafPending) return
rafPending = true
requestAnimationFrame(() => {
for (const key of dirty) {
const bucket = buckets.get(key)
if (bucket) bucket.data.value = bucket.data.value.slice()
}
dirty.clear()
rafPending = false
})
}The flush swaps in a new array reference (\.slice()) rather than notifying Vue about the same array mutated in place. This is deliberate: consumers read a bucket’s data through a computed, and Vue’s reactivity only re-notifies a computed’s dependents when the underlying value changes by identity — an in-place mutation on the same array reference would be swallowed at that boundary and never reach the views. Batching to one rAF-scheduled reference swap per bucket, instead of a reactive trigger per telemetry event, keeps Vue’s scheduler from competing with the Three.js render loop the 3D view depends on for a steady frame rate.
function onReconnect() {
for (const buildingId of new Set([...buckets.keys()].map(buildingOf))) {
socket.emit('subscribe_building', buildingId)
}
for (const [key, bucket] of buckets) {
void fetchBucket(key.slice(0, key.indexOf(':')) as SensorType, buildingOf(key), bucket)
}
}The server holds no memory of which rooms a reconnected socket was in — a fresh connection starts in no rooms at all. On connect (which also fires after every reconnect), the store re-joins every building it still has an active bucket for and re-fetches each one’s snapshot, so a dropped connection loses at most the telemetry that arrived while disconnected, not the subscription itself.
useBuildingSensorFile: frontend/src/composables/building/useBuildingSensor.ts
export function useBuildingSensor(buildingId: Ref<string | undefined>, type: SensorType) {
const store = useSensorDataStore()
const bucket = shallowRef<SensorBucket | null>(null)
watch(
buildingId,
(id, _old, onCleanup) => {
if (!id) { bucket.value = null; return }
bucket.value = store.acquire(id, type)
onCleanup(() => store.release(id, type))
},
{ immediate: true },
)
return {
data: computed(() => bucket.value?.data.value ?? []),
isLoading: computed(() => bucket.value?.isLoading.value ?? false),
error: computed(() => bucket.value?.error.value ?? null),
}
}This is the only entry point a component actually uses — it never touches sensorData.ts’s acquire/release directly. watch(buildingId, ..., { immediate: true }) handles three cases identically: the initial mount, a component switching to a different building, and unmount (via onCleanup) — each transition releases the old bucket reference (if any) and acquires the new one, so the store’s reference counts stay correct without the component needing any lifecycle logic of its own beyond calling this composable.