@nolag/track

Vehicle and asset GPS tracking with geofencing and an in-memory location buffer.

Overview

Track vehicles, delivery drivers, drones, or any moving asset in real time. @nolag/track broadcasts location updates to everyone watching a zone. Geofence detection runs client-side, using the haversine formula for circles and ray casting for polygons, so enter and exit events fire without a server round-trip. Zones group assets by geographic area or fleet, and a client can join several. Your app owns one core NoLag client and injects it into NoLagTrack; the wrapper attaches its behaviour to that connection.

Key Features

  • Realtime GPS location broadcast to zone subscribers
  • In-memory location buffer per asset on each client
  • Client-side geofence detection for circles and polygons
  • Geofences narrow the server-side location filter to the grid cells they cover
  • Asset presence per zone, plus an app-wide online lobby
  • Optional metadata on each location point

How It Works

NoLagTrack attaches to an injected @nolag/js-sdk client. Calling joinZone(name) returns a TrackingZone for the room name, sets the asset's presence there and subscribes to two topics: locations for GPS points and _geofence for geofence events published by your own services. Location history is a client-side buffer of the points this client has seen; the broker keeps nothing. Geofence evaluation happens locally on every location update, for your own points and for remote ones.

Geofences also shape what a zone receives. Every location is published with a filter naming its grid cell (squares of 0.01 degrees, roughly 1.1 km north to south). When you add or remove a geofence, the zone recomputes the cells its geofences cover and updates the filters on its locations subscription, so a zone with geofences only receives updates from those cells. With no geofences, and no filters of your own, the subscription is unfiltered and receives every update in the zone.

TopicPurpose
locationsGPS location points with optional metadata, tagged with their grid cell
_geofenceInbound GeofenceEvent messages from your own services. The SDK only listens here and re-emits them as geofenceTriggered; it never publishes to this topic

Locations are published with echo: false: your own point is stored and emitted locally by sendLocation(), and the wrapper ignores incoming points whose assetId is its own.

Limits worth knowing

  • kraken accepts at most 100 filters per subscription. Geofences that together cover more than 100 grid cells (one circle with a radius of roughly 4 to 5 km, depending on latitude, gets there) make the filter update fail with a too_many_filters error on the error event, and the zone keeps its previous filters. If the geofences were passed in the zones option, the zone's first subscribe to locations is refused the same way.
  • A zone with geofences only receives points from the covered cells, which are the bounding boxes of the shapes. A remote asset that jumps from inside a geofence straight to a point outside every covered cell produces no exit on that zone, because the outside point is never delivered.
  • Two clients that use the same assetId ignore each other's points. Give each tracked device its own id.
  • Nothing is stored on the broker. A client that joins a zone sees points published from then on; the buffer is per client and is empty after a page reload.

Topics to grant

WhatValue
App SlugWhatever you pass as appName (default track)
RoomsOne per zone name you pass to joinZone(). The blueprint defines fleet-zone
Topics in each roomlocations, _geofence
Lobbyonline
PermissionpubSub on locations for assets that report, subscribe for dashboards; subscribe on _geofence for clients, publish for the service that sends geofence events

Grant these either in kraken's static token file or in a @nolag/core project document. See Static auth and Project document for the full formats.

{
  "tokens": {
    "van-12-token": {
      "actorTokenId": "van-12",
      "projectId": "demo-project",
      "organizationId": "demo-org",
      "actorType": "device",
      "allowedTopics": [
        {
          "pattern": "track/fleet-zone/locations",
          "permission": "pubSub",
          "app_id": "track-app",
          "room_id": "room-fleet-zone",
          "room_slug": "fleet-zone"
        },
        {
          "pattern": "track/fleet-zone/_geofence",
          "permission": "subscribe",
          "app_id": "track-app",
          "room_id": "room-fleet-zone",
          "room_slug": "fleet-zone"
        }
      ],
      "allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "track-online" }]
    }
  }
}

Per-topic permissions like these are one rule each in auth.json. The project document above is an open app, so every actor in the project gets pubSub on both topics. In @nolag/core a grant carries one permission for all of the topics it lists, so splitting permissions per topic means separate apps, rooms or actors; see Access Control.

Things that matter with the static file:

  • Every token that watches or reports in the same zone needs the same app_id.
  • Asset presence only works in rooms that have a rule carrying both room_slug and room_id. A catch-all rule such as track/+/locations lets a token use any zone name, but presence stays silent in zones without their own rule.
  • Without the allowedLobbies entry the lobby subscribe is refused: the wrapper emits an error (not_authorized) and ready() only resolves after the core SDK's 10 second lobby timeout.

Installation

npm install @nolag/track @nolag/js-sdk

Shared connection. One core NoLag client can back several wrapper SDKs at once, as long as each wrapper uses a distinct appName. Each wrapper attaches its handlers on construction and releases them with detach(), and never touches the socket itself. Your app owns connect() and disconnect(). One connection holds presence in one room at a time, so an asset that joins several zones on one client is only listed as present in the zone it joined last; its location updates still reach every zone it publishes to.

Quick Start

import { NoLag } from '@nolag/js-sdk'
import { NoLagTrack } from '@nolag/track'

const client = NoLag('van-12-token', { url: 'ws://localhost:8080/ws' })

const tracker = new NoLagTrack({
  client,
  appName: 'track', // the App Slug you granted
  assetId: 'van-12',
  assetName: 'Van 12',
})

await client.connect()   // the app owns the connection
await tracker.ready()    // wrapper setup complete

const zone = tracker.joinZone('fleet-zone')

// Watch locations from every asset in the zone (and your own)
zone.on('locationUpdate', ({ assetId, point, metadata, timestamp }) => {
  console.log(`Asset ${assetId} at ${point.lat}, ${point.lng}`, metadata, timestamp)
})

zone.on('geofenceTriggered', ({ assetId, geofenceId, type, point }) => {
  console.log(`Asset ${assetId} ${type} geofence ${geofenceId} at ${point.lat}, ${point.lng}`)
})

// Device side: report a location. The point is stored locally and
// locationUpdate fires for it right away.
zone.sendLocation({ lat: 51.5074, lng: -0.1278, speed: 11.6 }, { driverId: 'drv_001' })

// A circular geofence (haversine distance check)
zone.addGeofence({
  id: 'depot-central',
  shape: 'circle',
  center: { lat: 51.5074, lng: -0.1278 },
  radiusMeters: 500,
  name: 'Central Depot',
})

// A polygon geofence (ray casting)
zone.addGeofence({
  id: 'zone-east',
  shape: 'polygon',
  points: [
    { lat: 51.52, lng: -0.05 },
    { lat: 51.50, lng: -0.03 },
    { lat: 51.48, lng: -0.06 },
    { lat: 51.50, lng: -0.09 },
  ],
  name: 'East Zone',
})
// From here on this zone only receives location updates from the grid cells
// the two geofences cover. Remove every geofence to receive all updates again.

// The in-memory buffer for one asset (synchronous)
const recent = zone.getLocationHistory('van-12')
console.log(`${recent.length} location points in buffer`)

// Teardown: the wrapper releases its handlers; the app closes the socket.
tracker.detach()
client.disconnect()

Reconnects

When the core client reconnects, the wrapper re-subscribes the online lobby, re-sets its presence in every joined zone and then emits reconnected. It does not re-subscribe the locations and _geofence topics: it was written for a broker that restores subscriptions on its own. Do not rely on that with kraken (see Production); re-issue the subscriptions yourself:

tracker.on('reconnected', () => {
  for (const zone of tracker.getZones()) {
    const room = client.setApp('track').setRoom(zone.name)
    room.subscribe('locations')
    room.subscribe('_geofence')
    // Re-applies the geofence cells plus your own filters to `locations`
    zone.setFilters(zone.filters)
  }
})

The wrapper's message handlers stay registered on the client, and the zone keeps its geofences and buffer, so this is enough. Points published while the client was away are not delivered later.

The online lobby. assetOnline, assetOffline and getOnlineAssets() depend on the app's online lobby. On kraken v0.9.0 a lobby covers the rooms in the subscribing token's own grants, and that room list is cached on each kraken node for 5 minutes after the last lobby subscribe there; after that, lobby events stop until something subscribes to the lobby again. assetJoined and assetLeft come from room presence and are not affected. See Lobbies.

API Reference

NoLagTrack

Constructor Options

OptionTypeDescription
clientNoLagSocketRequired. The injected core NoLag client the app owns and connects.
appNamestringThe App Slug used as the first segment of every topic (default 'track').
assetIdstringStable identifier for this asset (generated if omitted).
assetNamestringOptional human-readable name for this asset.
metadataRecord<string, unknown>Custom data attached to asset presence.
zoneNamesstring[]Tracking zones to join once the wrapper is ready.
zonesGeofence[]Client-side geofences to register on every joined zone.
maxLocationHistorynumberMax buffered points per asset (default 500).
debugbooleanEnable wrapper debug logging (default false).
Method / PropertyReturnsDescription
ready()Promise<void>Resolves once wrapper setup completed. joinZone() throws before this resolves.
detach()voidRelease this wrapper's handlers and topics; terminal, never closes the socket.
joinZone(name, opts?)TrackingZoneSubscribe to a zone and return it. Returns the existing instance if already joined (and then ignores opts). opts.filters adds location filter values of your own, unioned with the geofence cells.
leaveZone(name)voidUnsubscribe from a zone and release its resources.
getZones()TrackingZone[]All currently joined zones.
getOnlineAssets()TrackedAsset[]Assets currently seen as online.
localAssetTrackedAsset | nullThis asset, available once ready.

Events

EventPayloadDescription
connectednoneWrapper setup completed on a live connection.
disconnectedreason: stringConnection closed.
reconnectingnoneThe core client is attempting to reconnect.
reconnectednoneSetup ran again after a reconnect: lobby and presence re-applied (topics are not; see Reconnects).
errorerror: ErrorA transport or broker error, for example a refused subscribe or too many filters.
assetOnlineasset: TrackedAssetAn asset came online.
assetOfflineasset: TrackedAssetAn asset left the lobby.

TrackingZone

Method / PropertyReturnsDescription
sendLocation(point, metadata?, opts?)LocationUpdateBroadcast a GeoPoint with optional metadata and return the update that was sent. Tagged with its grid cell unless opts.filter replaces it, which opts the update out of geofence routing.
getLocationHistory(assetId?)LocationUpdate[]Points from this client's in-memory buffer (up to maxLocationHistory per asset). Omit assetId for all assets.
addGeofence(geofence)voidRegister a circle or polygon geofence, evaluated on every location update, and recompute the cell filter on locations.
removeGeofence(id)voidRemove a geofence by ID and recompute the filter.
getGeofences()Geofence[]All geofences registered on this zone.
getAssets()TrackedAsset[]Remote assets currently present in this zone.
getAsset(assetId)TrackedAsset | undefinedOne remote asset by ID.
filtersFilterValue[]Your own location filter values (the geofence cells are kept separately).
setFilters(values)voidReplace your own location filter values. They are unioned with the geofence cells, never replace them; an empty array leaves only the geofence cells.
addFilters(values) / removeFilters(values)voidAdd or remove your own filter values without touching the geofence cells.

Events

EventPayloadDescription
locationUpdateLocationUpdateA point arrived from another asset, or this asset sent one.
assetJoinedTrackedAssetAn asset set presence in this zone.
assetLeftTrackedAssetAn asset's presence left this zone.
geofenceTriggeredGeofenceEventAn asset crossed a geofence boundary, evaluated client-side, or an event arrived on _geofence.

Types

TypeMain fields
GeoPointlat, lng, altitude?, accuracy?, heading?, speed? (degrees, metres, metres per second)
LocationUpdateid, assetId, point: GeoPoint, metadata?, timestamp
Geofence{ id, shape: 'circle', center: GeoPoint, radiusMeters, name?, metadata? } or { id, shape: 'polygon', points: GeoPoint[], name?, metadata? }
GeofenceEventgeofenceId, assetId, type: 'enter' | 'exit', point: GeoPoint, timestamp
TrackedAssetassetId, actorTokenId, assetName?, lastLocation?, metadata?, joinedAt, isLocal