Skip to content

About

IoT Monitoring Platform - Full-stack TypeScript - React, Node, Apollo

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

IoT Monitoring Platform - TypeScript + Bun

This is a proof of concept: small in scope on purpose, but built on principles meant to carry straight over into a real product, not thrown away once the idea is proven. Its purpose is to show how far a single language and a single toolchain - TypeScript, running on Bun end to end - can carry a full-stack, real-time IoT monitoring application: one shared type system for the whole stack, no code-generation step, no build-tool zoo. The scenario is concrete (a greenhouse: temperature, humidity, soil moisture, CO2) but nothing about the architecture is specific to greenhouses - swap the sensors and the schema and the same shape holds.

What it's meant to demonstrate:

  • One shared source of truth, zero drift. The GraphQL schema, the domain types, and even the anomaly-detection math live once, in packages/shared, and are imported unmodified by the server, the React client, and the device simulator. Rename a field and every consumer fails to typecheck immediately - not at runtime, in production.
  • Real-time without extra infrastructure. GraphQL subscriptions over a plain WebSocket push every new reading and every detected anomaly to the UI the instant they happen - no polling, no separate message broker.
  • Basic analysis today, built to grow. A small statistical anomaly detector (a modified z-score) runs server-side out of the box. It lives in the same shared module where trend detection, forecasting, or alerting rules would go next - the point is the slot exists, not that this one algorithm is the final word.
  • Bun as the entire toolchain. One package manager, one workspace runner, and the server runs its TypeScript directly with no separate build/transpile step; Vite does the equivalent job for the client bundle.

What it deliberately leaves out, because a PoC doesn't need it yet: a networked database (readings live in an embedded SQLite file), auth, multi-tenant device fleets. None of that changes the architecture below - it scales by adding to it, not by replacing it.

Demo

YouTube video (no voice-over)

Screenshots

Dashboard - live metric cards, detected anomalies, and a per-metric timeseries chart:

Dashboard showing metric cards, an anomalies list, and a temperature timeseries chart

Swagger UI - interactive docs for the POST /readings ingest endpoint:

Swagger UI for the POST /readings endpoint

Architecture

flowchart LR
    Device[["IoT Device(s)"]]
    Server["Server\nGraphQL API · real-time · anomaly detection"]
    Client["Client\nReact Dashboard"]
    Shared[("Shared\nTypes + GraphQL Schema")]

    Device -- "REST: post a reading" --> Server
    Server <-- "GraphQL: query · mutate · subscribe" --> Client
    Shared -.-> Server
    Shared -.-> Client
Loading

Devices push readings in over plain REST; the server validates, stores, and scores each one for anomalies, then serves everything back to the client over one GraphQL API - reads, writes, and live subscriptions alike. Both ends of that API are built from the same shared schema and types, so they can't silently drift apart. (The fuller picture - the SQLite store, the detector, the pub/sub fan-out to subscriptions - is in Anomaly detection below.)

Tech stack

  • Bun workspaces - monorepo tooling, package manager, runtime for the server
  • SQLite (via Bun's built-in bun:sqlite) + Drizzle ORM/Kit - storage and migrations
  • Apollo Server (Express integration) - GraphQL API
  • graphql-ws - GraphQL subscriptions over WebSocket
  • React + Vite + DaisyUI + Chart.js - client app
  • Apollo Client - GraphQL queries/mutations/subscriptions in the browser
  • swagger-ui-express - interactive OpenAPI docs for the REST ingest endpoint
  • Docker (multi-stage builds) + GitHub Actions - build and publish images to GHCR
  • Playwright - end-to-end tests against the real client + server
  • TypeScript everywhere, ESLint (typescript-eslint) for linting

Project structure

apps/
  server/       @iot/server       - Apollo Server + REST ingest endpoint
                                    (+ SQLite store, drizzle/ migrations,
                                    Dockerfile)
  client/       @iot/client       - React + Vite + Apollo Client
                                    (+ Dockerfile, nginx.conf)
  mock-device/  @iot/mock-device  - dev-only fake sensor pushing readings
                                    (+ Dockerfile)
  e2e/          @iot/e2e          - Playwright end-to-end tests
packages/
  shared/   @iot/shared  - GraphQL schema, shared TS types, anomaly
            generation rules + detection algorithm
.github/workflows/docker-publish.yml  - build + push all three images to GHCR
docker-compose.yml       - run the whole stack locally, building from source
docker-compose.ghcr.yml  - run server + client from published GHCR images
api.http                 - sample requests (GraphQL + REST)

Setup

Requires Bun installed locally.

bun install

Running the dev servers

bun run dev          # server (:4000) + client (:5173) + mock device together
bun run dev:server   # server only
bun run dev:client   # client only
bun run dev:mock     # mock device only

Once running:

  • GraphQL HTTP endpoint: http://localhost:4000/graphql
  • GraphQL subscriptions (WebSocket): ws://localhost:4000/graphql
  • REST ingest endpoint for devices: http://localhost:4000/readings
  • Swagger docs for the REST endpoint: http://localhost:4001/ (raw spec at /openapi.json)
  • Client: http://localhost:5173

The server starts both the API (PORT, default 4000) and the Swagger docs (SWAGGER_PORT, default 4001) on separate ports in the same process - both come up automatically with bun run dev / bun run dev:server.

Mock device (dev only)

apps/mock-device simulates a real greenhouse sensor: every 3 seconds it posts one reading (temp, humidity, soilMoisture, co2) to POST /readings. Each value normally drifts 0.2-0.5% from its own previous value; every 6th reading injects an anomaly - one randomly chosen metric jumps by >10% - and the following reading resumes normally from the last non-anomalous value. The ranges and thresholds it generates against live in @iot/shared (see below), so generation and detection can never drift out of sync. It's only meant for local development - already included in bun run dev, or run it alone with bun run dev:mock. Point it at a different server with MOCK_TARGET_URL=http://localhost:4000/readings.

Anomaly detection

@iot/shared's anomaly/ module is the single source of truth for both sides of anomaly handling:

  • generation.ts - the per-metric operating ranges and deviation percentages the mock device generates against.
  • detection.ts - a modified (robust) z-score detector: each incoming value is scored against the median and median absolute deviation (MAD) of its own trailing window (last 20 readings, min. 5 before it'll score anything), rather than mean/standard deviation. Mean and stdDev are themselves dragged toward outliers, which blunts a classic z-score's ability to catch the very spikes it's meant to find; median/MAD stay stable in their presence. A |z| ≥ 3.5 flags a reading as anomalous.

Detection runs server-side: every reading, whether it arrives via POST /readings or the addReading mutation, is scored in store.ts against the trailing history before it, read straight out of SQLite. Anomalies are stored alongside the readings; Query.anomalies returns the most recent 10, and each new one is pushed live over a second subscription, Subscription.anomalyDetected.

The client just reflects that feed (useAnomalies hook - query + subscribe, no detection logic of its own): the metric card for the affected reading shows its value in red (nothing else changes on the card), and the Anomalies panel lists each one with a metric-specific icon (lucide-react), its value, and its z-score - no red in the list itself, just the icon-labeled rows.

Other useful scripts (run from the repo root, fan out to every workspace):

bun run typecheck
bun run lint
bun run lint:fix
bun run build

Database

Readings and anomalies are stored in SQLite, accessed through Drizzle ORM on Bun's built-in bun:sqlite driver. Two reasons for that pairing: bun:sqlite is part of the runtime, so there is no native module to compile and the Alpine-based server image stays exactly as small as it was; and it is synchronous, so the store functions stayed synchronous too and the resolvers didn't change at all when the arrays became tables.

apps/server/
  drizzle/            committed SQL migrations + drizzle-kit's journal
  drizzle.config.ts   drizzle-kit config (generate only - see Migrations)
  data/iot.db         the database itself (gitignored)
  src/db/
    schema.ts         table definitions - the single source of schema truth
    client.ts         opens the file, sets pragmas, runs migrations
    seed.ts           first-run seed data
  src/store.ts        the only module that reads/writes those tables

Schema

Two tables, mirroring the GraphQL types in @iot/shared:

readings anomalies
id text PK (uuid) id text PK (uuid)
seq int, unique seq int, indexed - the reading that tripped it
temp, humidity, soilMoisture, co2 real metric text, value real, zScore real
timestamp text (ISO-8601) timestamp text (ISO-8601)

Columns are deliberately camelCase rather than snake_case, so a selected row is a GreenhouseReading / AnomalyRecord and goes straight out over GraphQL with no mapping layer in between.

seq is the monotonic counter the dashboard charts and orders by. It used to be a module-level variable; it is now allocated as max(seq) + 1 inside the same transaction as the insert, so it survives restarts and can't be handed out twice.

How a reading gets written

store.ts keeps the same three functions it always had (createReading, listReadings, listAnomalies); only their insides moved to SQL:

  1. createReading opens a transaction, takes max(seq) + 1, inserts the row, and publishes it on readingAdded.
  2. It then reads the 20 readings before that one back out (WHERE seq < ? ORDER BY seq DESC LIMIT 20) and hands them to the detector as its trailing window.
  3. Any anomaly that comes back is inserted and published on anomalyDetected.

Reads are just the inverse: listReadings(limit) returns the newest limit ?? 80 rows re-sorted oldest-first (the order the chart plots), and listAnomalies returns the newest 10 ordered by rowid - a single reading can trip several metrics at once, and rowid is the only thing that keeps those ties in insertion order.

Nothing is ever deleted. The 80-reading / 10-anomaly limits the dashboard works with are query limits, not retention: the database keeps the full history, and Query.readings(limit) can ask for more of it.

Connecting and migrating

src/db/client.ts owns the connection. createDb(path) creates the parent directory if needed, opens the database, sets journal_mode = WAL (so anomaly-detection reads don't block on ingest writes) and foreign_keys = ON, then applies any pending migrations before returning the handle. Importing the module is therefore enough to get a usable, up-to-date schema - which is exactly what the server, the tests, and any one-off script all do.

The server calls that at startup, before it accepts requests, so bun run dev, the Docker image, and the e2e suite all self-heal. A failed migration takes the process down rather than serving requests against half a schema.

Migrations themselves are plain SQL files in apps/server/drizzle/, generated by drizzle-kit from schema.ts and committed to the repo:

# 1. edit apps/server/src/db/schema.ts
cd apps/server
bun run db:generate --name add_something   # writes drizzle/0001_add_something.sql
# 2. commit the generated .sql + meta/ files; restart the server to apply

drizzle-kit only ever generates - it diffs the schema against its own snapshot and never opens the database - so the Node-based CLI and the Bun-only bun:sqlite driver never have to meet.

Seeding

A brand-new database gets 10 readings and 10 anomalies, so the dashboard has a chart and an anomaly list on first load instead of an empty screen waiting on the mock device. seedIfEmpty generates them from the same METRIC_RANGES / deviation percentages the mock device uses (see Anomaly detection), walking each metric from its baseline and backdating timestamps at the device's 3-second cadence.

A fixed PRNG seed makes every fresh database byte-identical, so tests can assert on the values. The anomaly rows are written directly rather than produced by the detector - it needs 5 readings of history before it scores anything, so ten readings could never yield ten detected anomalies.

Seeding is skipped once the database holds any readings, so it is safe on every start, and DB_SEED=false turns it off entirely.

Where the file lives

Where Path
Local dev apps/server/data/iot.db (gitignored; WAL adds -wal/-shm siblings)
Docker /data/iot.db, on the server-data volume
Tests / e2e :memory:

DATABASE_PATH overrides all three; paths are resolved relative to the server package, not the cwd. Deleting the file is a full reset - the next start recreates, migrates, and re-seeds it. In Docker the database lives on a named volume so it survives docker compose down and image rebuilds; docker compose down -v is the equivalent reset.

Server unit tests run each case against a fresh :memory: database (plus one temp-file case that proves data survives a restart). They run under bun --bun vitest run, since a test that imports bun:sqlite needs the Bun runtime rather than the Node one bun run would otherwise pick for Vitest.

Browsing the database

bun run db:studio    # then open https://local.drizzle.studio

Drizzle Studio is a dev-only table browser for the same file the server uses. Because it reads schema.ts, it knows the shape of readings and anomalies rather than showing you raw SQLite: browse and sort rows, edit cells in place, insert or delete rows, and run arbitrary SQL. Useful for checking what the mock device has actually been writing, or hand-editing a value to provoke a case that's awkward to reach through the API.

It's safe to run next to bun run dev - the server keeps the database in WAL mode, which is built for exactly this kind of concurrent access. Point it at any other database file with the same env var the server uses:

DATABASE_PATH=/tmp/copy.db bun run db:studio

Two things worth knowing. The UI is a hosted page at https://local.drizzle.studio - the command starts a local server on 127.0.0.1:4983 and that page connects to it, so the page needs internet while your data never leaves localhost. (Browsing to 127.0.0.1:4983 yourself just returns a 404; it's an API, not the UI.) And drizzle-kit is a Node CLI with no knowledge of bun:sqlite, so Studio opens the file through @libsql/client (a devDependency, never imported by the app) while the server keeps using bun:sqlite. Two drivers, one file, which SQLite handles fine.

End-to-end tests

bun run e2e       # headless run
bun run e2e:ui    # Playwright's interactive UI mode

apps/e2e is a Playwright suite that drives the real client against the real server - no mocked GraphQL, no component testing. Its playwright.config.ts starts both apps/server and apps/client in dev mode automatically (and reuses already-running ones locally, so it's fast on repeat runs) and tears them down after. Tests post directly to POST /readings to control exactly what the server sees, then assert against the UI - covering the dashboard loading, a posted reading arriving live via the readingAdded subscription, metric-card selection driving the chart, and anomaly detection surfacing on both the metric card and the Anomalies panel.

Tests run serially (workers: 1) by design: they share one live server's state across the whole run, so parallel workers would race each other. That server is started with DATABASE_PATH=:memory:, so every run begins from the same freshly seeded database and never touches the dev one. Key elements carry data-testid attributes (metric-card-<name>, metric-value, chart-title, anomaly-row, connection-status) rather than relying on copy or CSS classes, so the tests don't break every time the wording changes.

Docker

Each app has its own Dockerfile (apps/server, apps/client, apps/mock-device), all built from the repo root as build context - Bun workspaces link packages via symlinks into one root node_modules, so bun install needs to see every workspace's package.json up front, and the image needs to keep the same apps/*/packages/* layout for those symlinks to resolve at runtime. packages/shared isn't standalone (it's a library, not a service), so it doesn't get its own image - it's copied into whichever app image needs it.

Quickstart: run from published images

No clone, no build, no Bun install, not even this repo on disk - docker-compose.ghcr.yml is fetched straight off GitHub and piped into docker compose, same idea as dotnet-angular's quickstart. It defines just the two "real" services - server and client:

curl -fsSL https://raw.githubusercontent.com/ssokurenko/fullstack-typescript-iot/main/docker-compose.ghcr.yml \
  | docker compose -p iot-demo -f - up -d

Open http://localhost:8080 - the dashboard starts empty; post readings yourself via api.http, curl, or the Swagger UI at http://localhost:4001.

Add the mock device on top (live demo data immediately) - it isn't part of the compose file since it's not a "real" service, just a demo data generator; attach it to the same network the compose stack created (<project>_default, so iot-demo_default for the -p iot-demo above):

docker run -d --name iot-mock-device --network iot-demo_default \
  -e MOCK_TARGET_URL=http://server:4000/readings \
  ghcr.io/ssokurenko/fullstack-typescript-iot-mock-device:latest

It starts streaming realistic readings (with periodic anomalies) right away - the fastest way to see the whole thing working. server resolves by that name because compose registers each service under its own name on the project's network, regardless of the container's actual name.

Stop and clean up:

docker rm -f iot-mock-device
curl -fsSL https://raw.githubusercontent.com/ssokurenko/fullstack-typescript-iot/main/docker-compose.ghcr.yml \
  | docker compose -p iot-demo -f - down

This pulls ghcr.io/ssokurenko/fullstack-typescript-iot-{server,client,mock-device}:latest, which only exist once the CI workflow below has run at least once (push to main). If the packages are still private, docker login ghcr.io first with a token that has read:packages.

Build from source

Run the whole stack with one command:

docker compose up --build
  • Client: http://localhost:8080
  • Server: http://localhost:4000/graphql, Swagger docs: http://localhost:4001
  • The bundled mock-device service points at the server service by its compose network name, so readings start flowing immediately - no manual setup needed.

Building a single image manually works the same way - context is always .:

docker build -f apps/server/Dockerfile -t iot-server .

The client is a static Vite build served by nginx, and Vite bakes VITE_* env vars into the bundle at build time, not runtime - to point a built client image at a different API, pass it as a build arg:

docker build -f apps/client/Dockerfile \
  --build-arg VITE_GRAPHQL_URL=https://api.example.com/graphql \
  -t iot-client .

CI: build & publish to GHCR

.github/workflows/docker-publish.yml runs on every push to main, every v*.*.* tag, and every PR into main:

  1. Quality gate - bun run typecheck and bun run lint across the whole workspace. Nothing gets built if this fails.

  2. Build & push - a matrix job builds all three images in parallel and pushes them to the GitHub Container Registry as:

    • ghcr.io/<owner>/<repo>-server
    • ghcr.io/<owner>/<repo>-client
    • ghcr.io/<owner>/<repo>-mock-device

    Each gets tagged with the branch name, the short commit SHA, latest (on main), and semver tags on version tags. On pull requests, images are built (to catch breakage) but never pushed. Layers are cached between runs via the GitHub Actions cache.

The workflow authenticates with the automatically-provided GITHUB_TOKEN (scoped to packages: write for that job) - no secrets to configure. Packages publish as private by default; make them public from the package's own settings on GitHub if you want anonymous docker pull.

Trying it out

The server starts with no readings - post some data first, then watch it appear live in the client.

Option A: the api.http file

Open api.http with the REST Client VS Code extension and click "Send Request" above any block. It includes:

  • a GraphQL query to fetch readings
  • a GraphQL mutation to add a reading
  • REST POST /readings examples, each posting a full reading (temp, humidity, soilMoisture, co2)
  • an invalid payload example that should 400

Option B: curl

curl -X POST http://localhost:4000/readings \
  -H 'Content-Type: application/json' \
  -d '{"temp": 42, "humidity": 42, "soilMoisture": 42, "co2": 800}'

Option C: any GraphQL client

Point a tool like Insomnia, Altair, or Apollo Sandbox at http://localhost:4000/graphql (queries/mutations) and ws://localhost:4000/graphql (subscriptions) - there's no built-in GraphQL Playground UI on this server, since it's mounted via expressMiddleware without a landing page plugin.

With the client open in a browser, any reading posted through the endpoints above shows up immediately via the readingAdded subscription.

Option D: Swagger UI

Open http://localhost:4001/ for interactive docs of POST /readings - request/response schemas, an example payload, and a "Try it out" button that sends a real request to the API on port 4000. The spec is hand-written in apps/server/src/openapi-spec.ts; only the REST endpoint is documented here since the GraphQL API is already self-describing via introspection.

About

IoT Monitoring Platform - Full-stack TypeScript - React, Node, Apollo

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

Contributors

Languages