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.
Dashboard - live metric cards, detected anomalies, and a per-metric timeseries chart:
Swagger UI - interactive docs for the POST /readings ingest endpoint:
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
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.)
- 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
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)
Requires Bun installed locally.
bun installbun 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 onlyOnce 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.
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.
@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.5flags 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 buildReadings 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
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.
store.ts keeps the same three functions it always had
(createReading, listReadings, listAnomalies); only their insides moved
to SQL:
createReadingopens a transaction, takesmax(seq) + 1, inserts the row, and publishes it onreadingAdded.- 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. - 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.
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 applydrizzle-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.
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 | 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.
bun run db:studio # then open https://local.drizzle.studioDrizzle 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:studioTwo 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.
bun run e2e # headless run
bun run e2e:ui # Playwright's interactive UI modeapps/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.
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.
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 -dOpen 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:latestIt 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 - downThis 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.
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-deviceservice points at theserverservice 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 ..github/workflows/docker-publish.yml
runs on every push to main, every v*.*.* tag, and every PR into main:
-
Quality gate -
bun run typecheckandbun run lintacross the whole workspace. Nothing gets built if this fails. -
Build & push - a matrix job builds all three images in parallel and pushes them to the GitHub Container Registry as:
ghcr.io/<owner>/<repo>-serverghcr.io/<owner>/<repo>-clientghcr.io/<owner>/<repo>-mock-device
Each gets tagged with the branch name, the short commit SHA,
latest(onmain), 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.
The server starts with no readings - post some data first, then watch it appear live in the client.
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 /readingsexamples, each posting a full reading (temp, humidity, soilMoisture, co2) - an invalid payload example that should 400
curl -X POST http://localhost:4000/readings \
-H 'Content-Type: application/json' \
-d '{"temp": 42, "humidity": 42, "soilMoisture": 42, "co2": 800}'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.
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.

