Architecture

Overview

Health Observability follows a serverless monolith pattern: one Cloudflare Worker handles both static asset delivery and the REST API. The frontend is a fully static SPA served from the same Worker origin, eliminating CORS friction for same-origin API calls (while CORS headers are still emitted for flexibility).

Component Map

graph LR subgraph Browser SPA["SPA (app.js)"] Charts["Chart.js Charts"] Tabs["Tab Manager"] end subgraph CloudflareWorker["Cloudflare Worker — worker/index.js"] Router["Method + Pathname Router"] BPHandler["Blood Pressure Handler"] WeightHandler["Weight Handler"] ProfileHandler["Profile Handler"] AssetsProxy["ASSETS Proxy (fallback)"] Utils["utils.js\n(selectBestReading · bpCategory\ncalculateDerivedMetrics · validateReadings)"] end subgraph D1["Cloudflare D1 (SQLite)"] Sessions["measurement_sessions"] Observations["observations"] MetricTypes["metric_types"] Tags["tags / session_tags"] WeightEntries["weight_entries"] Profile["profile"] CPAP["cpap_data"] end subgraph ExternalWorker["myair-sync Worker (external)"] Auth["Auth / MFA flow"] Sync["Sync scheduler"] CPAPStore["CPAP fetch endpoint"] end SPA -->|"/api/*"| Router SPA -->|"GET /"| AssetsProxy Router --> BPHandler Router --> WeightHandler Router --> ProfileHandler Router --> AssetsProxy BPHandler --> Utils BPHandler --> Sessions & Observations & MetricTypes & Tags WeightHandler --> WeightEntries ProfileHandler --> Profile SPA -->|"https://myair-sync..."| ExternalWorker ExternalWorker --> CPAPStore

Request Lifecycle

sequenceDiagram participant B as Browser participant W as Cloudflare Worker participant D as D1 Database participant A as ASSETS Binding B->>W: Any HTTP request W->>W: Match method + pathname alt /api/* route matched W->>W: Parse & validate request body W->>D: Prepared statement(s) via batch() D-->>W: Result rows W-->>B: JSON response (200/201/400/500) else OPTIONS preflight W-->>B: CORS headers, 200 else No route matched W->>A: env.ASSETS.fetch(request) A-->>B: Static file (HTML/JS/CSS) end

Routing Strategy

The Worker uses a simple linear router — no framework. Routes are matched by method + exact pathname (or startsWith for parameterised deletes):

POST   /api/blood-pressure          → handleBpPost
GET    /api/blood-pressure          → handleBpGet
DELETE /api/blood-pressure/:id      → handleBpDelete

POST   /api/weight                  → handleWeightPost
GET    /api/weight                  → handleWeightGet
DELETE /api/weight/:id              → handleWeightDelete

GET    /api/profile                 → handleProfileGet
PUT    /api/profile                 → handleProfilePut

OPTIONS *                           → CORS preflight
*      *                            → ASSETS fallback → 404

run_worker_first = true in wrangler.toml ensures API routes are evaluated before Cloudflare’s asset router.

Worker-First Asset Serving

flowchart TD Req["Incoming request"] RunFirst{"run_worker_first = true"} APIMatch{"pathname matches\n/api/* ?"} APILogic["Execute API handler"] ASSETSFetch["env.ASSETS.fetch(request)"] StaticFile["Serve static file"] NotFound["404 Not Found"] Req --> RunFirst RunFirst --> APIMatch APIMatch -->|Yes| APILogic APIMatch -->|No| ASSETSFetch ASSETSFetch -->|File exists| StaticFile ASSETSFetch -->|File missing| NotFound

Error Handling

All routes are wrapped in a top-level try/catch. Unhandled exceptions return a generic 500 JSON error. Route-level handlers return structured 400 errors for validation failures, with human-readable messages.

flowchart LR Handle["Handler function"] Handle -->|"Validation fails"| E400["err(message, 400)\n{error: '...'}"] Handle -->|"Success"| J201["json(data, 201)\n{...}"] Handle -->|"Throws"| Catch["Global catch\nerr('Internal server error', 500)"]

CORS Policy

All responses include:

Access-Control-Allow-Origin:  *
Access-Control-Allow-Methods: GET, POST, DELETE, PUT, OPTIONS
Access-Control-Allow-Headers: Content-Type

OPTIONS preflights short-circuit before any route matching.

Database Access Pattern

The Worker uses Cloudflare D1’s batch API (env.DB.batch([...stmts])) for write operations that span multiple tables. This provides atomicity within a single Worker invocation. Reads use individual prepared statements with parameterised bindings to prevent SQL injection.

sequenceDiagram participant H as Handler participant D as D1 H->>D: prepare(SQL).bind(...args).all() ← reads H->>H: Build array of prepared statements H->>D: batch([stmt1, stmt2, ...]) ← atomic writes D-->>H: BatchResult[]

Security Posture

ConcernMitigation
SQL InjectionD1 parameterised prepared statements throughout
Input validationServer-side range checks on all numeric fields; array/type checks on tags
XSSNo server-side HTML rendering; JSON-only API
AuthNo authentication layer (personal/single-user tool)
CORSWildcard * — acceptable for a personal tool; tighten if needed