diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..93e43d3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +certs +.env +.git +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c1d954d --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# --- Server ----------------------------------------------------------------- +HOST=0.0.0.0 +PORT=3000 +LOG_LEVEL=info + +# --- Database ---------------------------------------------------------------- +# When running via docker-compose this is overridden to use the `db` service. +DATABASE_URL=postgres://mccc:mccc@localhost:5432/mccc + +# --- History ----------------------------------------------------------------- +# Minimum seconds between two history snapshots written on ingest. +HISTORY_INTERVAL_SECONDS=300 + +# --- TLS --------------------------------------------------------------------- +# A self-signed certificate is generated automatically into ./certs when these +# files do not exist. Set TLS_ENABLED=false to serve plain HTTP/WS instead. +TLS_ENABLED=true +TLS_CERT_PATH=./certs/cert.pem +TLS_KEY_PATH=./certs/key.pem + +# --- Misc -------------------------------------------------------------------- +# Allowed browser origin for the frontend. Use a concrete URL in production. +CORS_ORIGIN=* +# Base URL advertised in the OpenAPI document. +PUBLIC_URL=https://localhost:3000 diff --git a/.gitignore b/.gitignore index 6aa492c..0afd6f8 100644 --- a/.gitignore +++ b/.gitignore @@ -215,3 +215,10 @@ fabric.properties # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser +node_modules/ +dist/ +certs/ +.env +*.log +npm-debug.log* +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b3a609 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# --- Build stage ------------------------------------------------------------- +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# --- Runtime stage ------------------------------------------------------------ +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force +COPY --from=build /app/dist ./dist +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 3f3925a..378e8fe 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,100 @@ -# mc-computer-craft-api-backend +# Minecraft ComputerCraft ME API — Backend +TypeScript backend that collects Minecraft ME system data pushed by a +ComputerCraft computer and exposes it over **HTTPS** (REST) and **WSS** +(live websocket feed). + +This is the TypeScript rewrite of the original Rust/Axum + SQLite backend. + +## Stack + +- **[Fastify 5](https://fastify.dev/)** — HTTP + WebSocket server. Chosen over + Express/Nest because it has first-class WebSocket, JSON-schema validation and + OpenAPI support built in, which keeps the project small for this scope. +- **PostgreSQL** — runs in Docker via `docker-compose`. +- **`@fastify/swagger` + `@fastify/swagger-ui`** — OpenAPI docs at `/api/docs`. +- **TypeBox** — schemas used both for request validation and OpenAPI generation. +- **`selfsigned`** — generates a self-signed TLS certificate on first start. + +## Data model + +Two tables, mirroring the original design: + +| Table | Purpose | +| ------------------- | -------------------------------------------------- | +| `me_system_live` | current contents — one row per item (`name` unique) | +| `me_system_history` | historic snapshots — references `me_system_live.id` | + +The `mod` of each item (the namespace before the `:` in `minecraft:iron_ingot`) +is derived on ingest and stored on the live row so it can be filtered cheaply. + +## Quick start (Docker) + +```bash +cp .env.example .env +docker compose up --build +``` + +This starts PostgreSQL and the backend. A self-signed certificate is generated +into `./certs` automatically. + +## Quick start (local dev) + +```bash +cp .env.example .env # adjust DATABASE_URL to your local Postgres +npm install +npm run dev +``` + +## Accept the self-signed certificate + +Because the certificate is self-signed, browsers reject it until trusted. +**Open once and accept the warning** — this +also makes `fetch` and `wss://` calls from the frontend work. + +## Endpoints + +| Method | Path | Description | +| ------ | -------------------------- | ---------------------------------------------- | +| `POST` | `/api/me-system/update` | Ingest a full snapshot (called by ComputerCraft) | +| `GET` | `/api/me-system/live` | Current contents — `search`, `mod`, `sort`, `order` | +| `GET` | `/api/me-system/mods` | Aggregated per-mod overview | +| `GET` | `/api/me-system/history` | History points for one item — `name`, `limit` | +| `GET` | `/api/health` | Health and runtime metrics | +| `WS` | `/api/ws/live` | Live data feed (see below) | +| `GET` | `/api/docs` | OpenAPI / Swagger UI | + +### WebSocket protocol — `/api/ws/live` + +- On connect the server sends a `snapshot` message. +- The client may send `{ "type": "setFilters", "filters": { "search": "iron", + "mod": "minecraft", "sort": "amount", "order": "desc" } }`. +- The server replies with a fresh `snapshot` and keeps the filter for every + later `update` message — so filters persist across data updates. +- On every ingest the server pushes an `update` message to each client, with + that client's filter already applied. + +Message shape: + +```json +{ "type": "snapshot|update", "items": [...], "mods": [...], "filters": {...} } +``` + +## Configuration + +See `.env.example`. Key variables: `PORT`, `DATABASE_URL`, +`HISTORY_INTERVAL_SECONDS`, `TLS_ENABLED`, `CORS_ORIGIN`. + +## ComputerCraft uploader + +An example uploader script is provided in `computercraft/me_uploader.lua`. +See the comment at the top of that file regarding the self-signed certificate. + +## Scripts + +| Command | Description | +| ------------------ | --------------------------------- | +| `npm run dev` | Watch-mode dev server (`tsx`) | +| `npm run build` | Compile TypeScript to `dist/` | +| `npm start` | Run the compiled server | +| `npm run typecheck`| Type-check without emitting | diff --git a/computercraft/me_uploader.lua b/computercraft/me_uploader.lua new file mode 100644 index 0000000..1d86974 --- /dev/null +++ b/computercraft/me_uploader.lua @@ -0,0 +1,60 @@ +--[[ + ME system uploader for ComputerCraft (CC: Tweaked). + + Requires an "ME Bridge" peripheral (Advanced Peripherals) connected to the + computer and wired to an Applied Energistics 2 / Refined Storage network. + + It periodically reads the network contents and POSTs them to the backend's + /api/me-system/update endpoint. + + NOTE: the backend serves HTTPS with a self-signed certificate by default. + CC: Tweaked validates TLS certificates, so either: + * point API_URL at an http:// endpoint (run the backend with + TLS_ENABLED=false, ideally behind a trusted reverse proxy), or + * terminate TLS with a proxy that uses a certificate CC trusts. +]] + +local API_URL = "http://your-server:3000/api/me-system/update" +local INTERVAL = 10 -- seconds between uploads + +local bridge = peripheral.find("meBridge") +if not bridge then + error("No ME Bridge peripheral found") +end + +local function collect() + local items = bridge.listItems() + local payload = {} + for _, item in ipairs(items) do + payload[#payload + 1] = { + name = item.name, + amount = item.amount or item.count or 0, + displayName = item.displayName, + } + end + return payload +end + +local function upload(payload) + local body = textutils.serializeJSON(payload) + local response, err = http.post(API_URL, body, { + ["Content-Type"] = "application/json", + }) + if not response then + print("Upload failed: " .. tostring(err)) + return + end + print("Uploaded " .. #payload .. " items -> " .. response.readAll()) + response.close() +end + +print("ME uploader started. Target: " .. API_URL) +while true do + local ok, payload = pcall(collect) + if ok then + pcall(upload, payload) + else + print("Collect failed: " .. tostring(payload)) + end + sleep(INTERVAL) +end diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..55763bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: mccc + POSTGRES_PASSWORD: mccc + POSTGRES_DB: mccc + volumes: + - db-data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mccc -d mccc"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + HOST: 0.0.0.0 + PORT: 3000 + DATABASE_URL: postgres://mccc:mccc@db:5432/mccc + HISTORY_INTERVAL_SECONDS: 300 + TLS_ENABLED: "true" + CORS_ORIGIN: "*" + PUBLIC_URL: https://localhost:3000 + ports: + - "3000:3000" + volumes: + # Persists the auto-generated self-signed certificate between restarts. + - ./certs:/app/certs + +volumes: + db-data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef406f1 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "mc-cc-api-backend", + "version": "1.0.0", + "description": "TypeScript backend for collecting Minecraft ME system data via ComputerCraft and exposing it over HTTPS + WSS.", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fastify/cors": "^10.0.1", + "@fastify/swagger": "^9.4.0", + "@fastify/swagger-ui": "^5.2.0", + "@fastify/type-provider-typebox": "^5.1.0", + "@fastify/websocket": "^11.0.1", + "@sinclair/typebox": "^0.34.9", + "dotenv": "^16.4.7", + "fastify": "^5.2.0", + "pg": "^8.13.1", + "selfsigned": "^2.4.1" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/pg": "^8.11.10", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..94df3ba --- /dev/null +++ b/src/config.ts @@ -0,0 +1,39 @@ +import 'dotenv/config'; + +function readEnv(key: string, fallback?: string): string { + const value = process.env[key]; + if (value === undefined || value === '') { + if (fallback !== undefined) return fallback; + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +function readBool(key: string, fallback: boolean): boolean { + const value = process.env[key]; + if (value === undefined || value === '') return fallback; + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); +} + +export const config = { + /** Interface the HTTP/WS server binds to. */ + host: readEnv('HOST', '0.0.0.0'), + /** Port for the combined HTTPS + WSS server. */ + port: Number(readEnv('PORT', '3000')), + /** PostgreSQL connection string. */ + databaseUrl: readEnv('DATABASE_URL', 'postgres://mccc:mccc@localhost:5432/mccc'), + /** How often (seconds) a history snapshot is written on ingest. */ + historyIntervalSeconds: Number(readEnv('HISTORY_INTERVAL_SECONDS', '300')), + /** TLS configuration. A self-signed certificate is generated automatically when missing. */ + tls: { + enabled: readBool('TLS_ENABLED', true), + certPath: readEnv('TLS_CERT_PATH', './certs/cert.pem'), + keyPath: readEnv('TLS_KEY_PATH', './certs/key.pem'), + }, + /** CORS origin allowed for browser clients (the frontend). */ + corsOrigin: readEnv('CORS_ORIGIN', '*'), + /** Public base URL advertised in the OpenAPI document. */ + publicUrl: readEnv('PUBLIC_URL', 'https://localhost:3000'), +} as const; + +export type AppConfig = typeof config; diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..e034b3e --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,7 @@ +import { pool } from './pool.js'; +import { SCHEMA_SQL } from './schema.js'; + +/** Applies the (idempotent) database schema. Runs on every startup. */ +export async function runMigrations(): Promise { + await pool.query(SCHEMA_SQL); +} diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..9cf4969 --- /dev/null +++ b/src/db/pool.ts @@ -0,0 +1,16 @@ +import pg from 'pg'; +import { config } from '../config.js'; + +// PostgreSQL returns BIGINT (OID 20) as a string by default to avoid precision +// loss. Minecraft item counts stay far below Number.MAX_SAFE_INTEGER, so we +// parse them straight into JS numbers for convenient JSON serialization. +pg.types.setTypeParser(20, (value) => Number(value)); + +export const pool = new pg.Pool({ + connectionString: config.databaseUrl, + max: 10, +}); + +export async function closePool(): Promise { + await pool.end(); +} diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..6ce8290 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,28 @@ +/** + * Database schema. Kept as an embedded string so it ships inside the compiled + * bundle without an extra file-copy step. The statements are idempotent and + * run on every startup. + */ +export const SCHEMA_SQL = ` +-- Current ME system contents. One row per distinct item. +CREATE TABLE IF NOT EXISTS me_system_live ( + id SERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, -- e.g. 'minecraft:iron_ingot' + display_name TEXT, -- human readable label + mod TEXT NOT NULL DEFAULT 'unknown', -- namespace, derived from name + amount BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Historic snapshots. References the live row to keep storage compact. +CREATE TABLE IF NOT EXISTS me_system_history ( + id BIGSERIAL PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES me_system_live(id) ON DELETE CASCADE, + amount BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_history_item_time ON me_system_history (item_id, recorded_at DESC); +CREATE INDEX IF NOT EXISTS idx_live_mod ON me_system_live (mod); +CREATE INDEX IF NOT EXISTS idx_live_amount ON me_system_live (amount DESC); +`; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..aac2bde --- /dev/null +++ b/src/index.ts @@ -0,0 +1,33 @@ +import { config } from './config.js'; +import { closePool } from './db/pool.js'; +import { runMigrations } from './db/migrate.js'; +import { buildServer } from './server.js'; + +async function main(): Promise { + await runMigrations(); + + const app = await buildServer(); + await app.listen({ host: config.host, port: config.port }); + + const scheme = config.tls.enabled ? 'https' : 'http'; + app.log.info(`API docs: ${scheme}://localhost:${config.port}/api/docs`); + app.log.info(`Live feed: ${scheme === 'https' ? 'wss' : 'ws'}://localhost:${config.port}/api/ws/live`); + + let closing = false; + const shutdown = async (signal: string): Promise => { + if (closing) return; + closing = true; + app.log.info(`Received ${signal}, shutting down`); + await app.close(); + await closePool(); + process.exit(0); + }; + + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); +} + +main().catch((err) => { + console.error('Fatal startup error:', err); + process.exit(1); +}); diff --git a/src/lib/modName.ts b/src/lib/modName.ts new file mode 100644 index 0000000..5e50e5f --- /dev/null +++ b/src/lib/modName.ts @@ -0,0 +1,12 @@ +/** + * Derives the originating mod from a Minecraft item id. + * + * Item ids follow the `namespace:path` convention, where the namespace is the + * mod id (e.g. `minecraft:iron_ingot` -> `minecraft`, `create:zinc_ingot` -> + * `create`). Ids without a namespace fall back to `unknown`. + */ +export function modFromName(name: string): string { + const separator = name.indexOf(':'); + if (separator <= 0) return 'unknown'; + return name.slice(0, separator); +} diff --git a/src/lib/tls.ts b/src/lib/tls.ts new file mode 100644 index 0000000..0956ee1 --- /dev/null +++ b/src/lib/tls.ts @@ -0,0 +1,41 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import selfsigned from 'selfsigned'; + +export interface TlsMaterial { + key: Buffer; + cert: Buffer; +} + +/** + * Loads the TLS key/cert pair from disk. If either file is missing a fresh + * self-signed certificate for `localhost` is generated and persisted, so the + * server runs over HTTPS/WSS out of the box without manual setup. + */ +export function ensureTlsMaterial(certPath: string, keyPath: string): TlsMaterial { + if (existsSync(certPath) && existsSync(keyPath)) { + return { cert: readFileSync(certPath), key: readFileSync(keyPath) }; + } + + const pems = selfsigned.generate([{ name: 'commonName', value: 'localhost' }], { + days: 3650, + keySize: 2048, + algorithm: 'sha256', + extensions: [ + { + name: 'subjectAltName', + altNames: [ + { type: 2, value: 'localhost' }, + { type: 7, ip: '127.0.0.1' }, + ], + }, + ], + }); + + mkdirSync(dirname(certPath), { recursive: true }); + mkdirSync(dirname(keyPath), { recursive: true }); + writeFileSync(certPath, pems.cert); + writeFileSync(keyPath, pems.private); + + return { cert: Buffer.from(pems.cert), key: Buffer.from(pems.private) }; +} diff --git a/src/repositories/meSystem.ts b/src/repositories/meSystem.ts new file mode 100644 index 0000000..5393e65 --- /dev/null +++ b/src/repositories/meSystem.ts @@ -0,0 +1,206 @@ +import { pool } from '../db/pool.js'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface LiveItem { + name: string; + displayName: string | null; + mod: string; + amount: number; + updatedAt: string; +} + +export interface ModInfo { + mod: string; + itemCount: number; + total: number; +} + +export interface HistoryPoint { + amount: number; + recordedAt: string; +} + +/** An item as written by the ingest pipeline, with the mod already derived. */ +export interface NormalizedItem { + name: string; + amount: number; + displayName: string | null; + mod: string; +} + +export const SORT_COLUMNS = ['amount', 'name', 'displayName', 'mod', 'updatedAt'] as const; +export type SortColumn = (typeof SORT_COLUMNS)[number]; +export type SortOrder = 'asc' | 'desc'; + +const SORT_SQL: Record = { + amount: 'amount', + name: 'name', + displayName: 'display_name', + mod: 'mod', + updatedAt: 'updated_at', +}; + +export interface GetLiveParams { + search?: string; + mod?: string | null; + sort?: SortColumn; + order?: SortOrder; +} + +/* ------------------------------------------------------------------ */ +/* Row mappers */ +/* ------------------------------------------------------------------ */ + +function toIso(value: unknown): string { + return value instanceof Date ? value.toISOString() : String(value); +} + +function rowToLiveItem(row: Record): LiveItem { + return { + name: row.name as string, + displayName: (row.display_name as string | null) ?? null, + mod: row.mod as string, + amount: Number(row.amount), + updatedAt: toIso(row.updated_at), + }; +} + +/* ------------------------------------------------------------------ */ +/* Writes */ +/* ------------------------------------------------------------------ */ + +/** + * Replaces the live snapshot: every existing amount is reset to 0, then the + * incoming items are upserted in a single bulk statement. Wrapped in a + * transaction so readers never observe a partial update. + */ +export async function upsertLive(items: NormalizedItem[]): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('UPDATE me_system_live SET amount = 0, updated_at = now() WHERE amount <> 0'); + + if (items.length > 0) { + await client.query( + `INSERT INTO me_system_live (name, display_name, mod, amount, updated_at) + SELECT u.name, u.display_name, u.mod, u.amount, now() + FROM UNNEST($1::text[], $2::text[], $3::text[], $4::bigint[]) + AS u(name, display_name, mod, amount) + ON CONFLICT (name) DO UPDATE SET + display_name = EXCLUDED.display_name, + mod = EXCLUDED.mod, + amount = EXCLUDED.amount, + updated_at = now()`, + [ + items.map((i) => i.name), + items.map((i) => i.displayName), + items.map((i) => i.mod), + items.map((i) => i.amount), + ], + ); + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +/** Appends a historic snapshot row for each provided item. */ +export async function snapshotHistory(items: NormalizedItem[]): Promise { + if (items.length === 0) return; + await pool.query( + `INSERT INTO me_system_history (item_id, amount) + SELECT l.id, u.amount + FROM UNNEST($1::text[], $2::bigint[]) AS u(name, amount) + JOIN me_system_live l ON l.name = u.name`, + [items.map((i) => i.name), items.map((i) => i.amount)], + ); +} + +/* ------------------------------------------------------------------ */ +/* Reads */ +/* ------------------------------------------------------------------ */ + +/** Returns the live snapshot, filtered and sorted according to `params`. */ +export async function getLive(params: GetLiveParams): Promise { + const conditions: string[] = []; + const values: unknown[] = []; + + const search = params.search?.trim(); + if (search) { + values.push(`%${search}%`); + conditions.push(`(name ILIKE $${values.length} OR display_name ILIKE $${values.length})`); + } + if (params.mod) { + values.push(params.mod); + conditions.push(`mod = $${values.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + // sortColumn and order come from fixed whitelists -> safe to interpolate. + const sortColumn = SORT_SQL[params.sort ?? 'amount']; + const order = params.order === 'asc' ? 'ASC' : 'DESC'; + + const result = await pool.query( + `SELECT name, display_name, mod, amount, updated_at + FROM me_system_live + ${where} + ORDER BY ${sortColumn} ${order}, name ASC`, + values, + ); + return result.rows.map(rowToLiveItem); +} + +/** Looks up a single live item by its id. */ +export async function getLiveItem(name: string): Promise { + const result = await pool.query( + `SELECT name, display_name, mod, amount, updated_at + FROM me_system_live WHERE name = $1`, + [name], + ); + const row = result.rows[0]; + return row ? rowToLiveItem(row) : null; +} + +/** Aggregated overview of every mod present in the live snapshot. */ +export async function getMods(): Promise { + const result = await pool.query( + `SELECT mod, + COUNT(*)::int AS item_count, + COALESCE(SUM(amount), 0)::bigint AS total + FROM me_system_live + GROUP BY mod + ORDER BY mod ASC`, + ); + return result.rows.map((row) => ({ + mod: row.mod as string, + itemCount: Number(row.item_count), + total: Number(row.total), + })); +} + +/** + * Returns the most recent `limit` history points for an item, ordered + * chronologically (oldest first) so they can be plotted directly. + */ +export async function getHistory(name: string, limit = 500): Promise { + const result = await pool.query( + `SELECT h.amount, h.recorded_at + FROM me_system_history h + JOIN me_system_live l ON l.id = h.item_id + WHERE l.name = $1 + ORDER BY h.recorded_at DESC + LIMIT $2`, + [name, limit], + ); + return result.rows + .map((row) => ({ amount: Number(row.amount), recordedAt: toIso(row.recorded_at) })) + .reverse(); +} diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..ceeb4c7 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,21 @@ +import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { wsHub } from '../ws/hub.js'; +import { HealthResponseSchema } from '../schemas/meSystem.js'; + +export const healthRoutes: FastifyPluginAsyncTypebox = async (app) => { + app.get( + '/api/health', + { + schema: { + tags: ['system'], + summary: 'Service health and basic runtime metrics.', + response: { 200: HealthResponseSchema }, + }, + }, + async () => ({ + status: 'ok', + uptime: process.uptime(), + websocketClients: wsHub.size, + }), + ); +}; diff --git a/src/routes/meSystem.ts b/src/routes/meSystem.ts new file mode 100644 index 0000000..b82e14f --- /dev/null +++ b/src/routes/meSystem.ts @@ -0,0 +1,100 @@ +import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import * as repo from '../repositories/meSystem.js'; +import { processUpdate } from '../services/meSystemService.js'; +import { + ErrorSchema, + HistoryQuerySchema, + HistoryResponseSchema, + LiveQuerySchema, + LiveResponseSchema, + ModInfoSchema, + UpdateBodySchema, + UpdateResponseSchema, +} from '../schemas/meSystem.js'; +import { Type } from '@sinclair/typebox'; + +export const meSystemRoutes: FastifyPluginAsyncTypebox = async (app) => { + /* -------------------------------------------------------------- */ + /* Ingest (called by the ComputerCraft computer) */ + /* -------------------------------------------------------------- */ + app.post( + '/api/me-system/update', + { + schema: { + tags: ['me-system'], + summary: 'Ingest a full snapshot of the ME system contents.', + description: + 'Replaces the live snapshot and, at most once per HISTORY_INTERVAL_SECONDS, ' + + 'writes a history snapshot. Connected websocket clients are notified.', + body: UpdateBodySchema, + response: { 200: UpdateResponseSchema }, + }, + }, + async (request) => { + const result = await processUpdate(request.body); + return { status: 'ok', success: result.success, historyWritten: result.historyWritten }; + }, + ); + + /* -------------------------------------------------------------- */ + /* Live data (HTTP fallback for the websocket feed) */ + /* -------------------------------------------------------------- */ + app.get( + '/api/me-system/live', + { + schema: { + tags: ['me-system'], + summary: 'Current ME system contents with optional filtering and sorting.', + querystring: LiveQuerySchema, + response: { 200: LiveResponseSchema }, + }, + }, + async (request) => { + const { search, mod, sort, order } = request.query; + const [items, mods] = await Promise.all([ + repo.getLive({ search, mod: mod ?? null, sort, order }), + repo.getMods(), + ]); + return { items, mods }; + }, + ); + + /* -------------------------------------------------------------- */ + /* Mods overview */ + /* -------------------------------------------------------------- */ + app.get( + '/api/me-system/mods', + { + schema: { + tags: ['me-system'], + summary: 'Aggregated overview of every mod present in the ME system.', + response: { 200: Type.Array(ModInfoSchema) }, + }, + }, + async () => repo.getMods(), + ); + + /* -------------------------------------------------------------- */ + /* Item history (for the detail charts) */ + /* -------------------------------------------------------------- */ + app.get( + '/api/me-system/history', + { + schema: { + tags: ['me-system'], + summary: 'Historic amount data points for a single item.', + querystring: HistoryQuerySchema, + response: { 200: HistoryResponseSchema, 404: ErrorSchema }, + }, + }, + async (request, reply) => { + const item = await repo.getLiveItem(request.query.name); + if (!item) { + reply.code(404); + return { error: `Item not found: ${request.query.name}` }; + } + const points = await repo.getHistory(request.query.name, request.query.limit ?? 500); + return { item, points }; + }, + ); +}; diff --git a/src/routes/ws.ts b/src/routes/ws.ts new file mode 100644 index 0000000..e9cd45b --- /dev/null +++ b/src/routes/ws.ts @@ -0,0 +1,38 @@ +import type { FastifyInstance } from 'fastify'; +import { sanitizeFilters, wsHub } from '../ws/hub.js'; + +/** + * Live data websocket. + * + * Protocol: + * - On connect the server sends a `snapshot` message. + * - The client may send `{ "type": "setFilters", "filters": {...} }` to change + * its search / sort / mod filter. The server immediately replies with a + * fresh `snapshot` and keeps the filter for all later `update` messages. + * - On every ingest the server pushes an `update` message to each client, + * with that client's filter already applied. + */ +export async function wsRoutes(app: FastifyInstance): Promise { + app.get('/api/ws/live', { websocket: true, schema: { hide: true } }, (socket) => { + const client = wsHub.register(socket); + + wsHub.sendSnapshot(client).catch((err) => app.log.error(err, 'ws snapshot failed')); + + socket.on('message', (raw: Buffer) => { + let message: unknown; + try { + message = JSON.parse(raw.toString()); + } catch { + return; + } + const parsed = message as { type?: string; filters?: unknown }; + if (parsed?.type === 'setFilters') { + client.filters = sanitizeFilters(parsed.filters); + wsHub.sendSnapshot(client).catch((err) => app.log.error(err, 'ws filter refresh failed')); + } + }); + + socket.on('close', () => wsHub.unregister(client)); + socket.on('error', () => wsHub.unregister(client)); + }); +} diff --git a/src/schemas/meSystem.ts b/src/schemas/meSystem.ts new file mode 100644 index 0000000..992cfe6 --- /dev/null +++ b/src/schemas/meSystem.ts @@ -0,0 +1,107 @@ +import { Type } from '@sinclair/typebox'; + +/* ------------------------------------------------------------------ */ +/* Ingest */ +/* ------------------------------------------------------------------ */ + +export const UpdateItemSchema = Type.Object( + { + name: Type.String({ + description: 'Minecraft item id (namespace:path).', + examples: ['minecraft:iron_ingot'], + }), + amount: Type.Integer({ minimum: 0, description: 'Stored quantity.' }), + displayName: Type.Optional( + Type.Union([Type.String(), Type.Null()], { description: 'Human readable label.' }), + ), + }, + { description: 'A single ME system item as reported by ComputerCraft.' }, +); + +export const UpdateBodySchema = Type.Array(UpdateItemSchema, { + description: 'Full snapshot of the ME system contents.', +}); + +export const UpdateResponseSchema = Type.Object({ + status: Type.String(), + success: Type.Integer({ description: 'Number of items processed.' }), + historyWritten: Type.Boolean({ description: 'Whether a history snapshot was written.' }), +}); + +/* ------------------------------------------------------------------ */ +/* Live data */ +/* ------------------------------------------------------------------ */ + +export const LiveItemSchema = Type.Object({ + name: Type.String(), + displayName: Type.Union([Type.String(), Type.Null()]), + mod: Type.String(), + amount: Type.Integer(), + updatedAt: Type.String({ format: 'date-time' }), +}); + +export const ModInfoSchema = Type.Object({ + mod: Type.String(), + itemCount: Type.Integer(), + total: Type.Integer(), +}); + +export const LiveQuerySchema = Type.Object({ + search: Type.Optional(Type.String({ description: 'Free-text filter on id and display name.' })), + mod: Type.Optional(Type.String({ description: 'Restrict results to a single mod.' })), + sort: Type.Optional( + Type.Union( + [ + Type.Literal('amount'), + Type.Literal('name'), + Type.Literal('displayName'), + Type.Literal('mod'), + Type.Literal('updatedAt'), + ], + { default: 'amount' }, + ), + ), + order: Type.Optional( + Type.Union([Type.Literal('asc'), Type.Literal('desc')], { default: 'desc' }), + ), +}); + +export const LiveResponseSchema = Type.Object({ + items: Type.Array(LiveItemSchema), + mods: Type.Array(ModInfoSchema), +}); + +/* ------------------------------------------------------------------ */ +/* History */ +/* ------------------------------------------------------------------ */ + +export const HistoryQuerySchema = Type.Object({ + name: Type.String({ description: 'Item id to load history for.' }), + limit: Type.Optional( + Type.Integer({ minimum: 1, maximum: 5000, default: 500, description: 'Max data points.' }), + ), +}); + +export const HistoryPointSchema = Type.Object({ + amount: Type.Integer(), + recordedAt: Type.String({ format: 'date-time' }), +}); + +export const HistoryResponseSchema = Type.Object({ + item: LiveItemSchema, + points: Type.Array(HistoryPointSchema), +}); + +/* ------------------------------------------------------------------ */ +/* Shared */ +/* ------------------------------------------------------------------ */ + +export const ErrorSchema = Type.Object({ + error: Type.String(), +}); + +export const HealthResponseSchema = Type.Object({ + status: Type.String(), + uptime: Type.Number({ description: 'Process uptime in seconds.' }), + websocketClients: Type.Integer(), +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..660f8de --- /dev/null +++ b/src/server.ts @@ -0,0 +1,62 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import cors from '@fastify/cors'; +import swagger from '@fastify/swagger'; +import swaggerUi from '@fastify/swagger-ui'; +import websocket from '@fastify/websocket'; +import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { config } from './config.js'; +import { ensureTlsMaterial } from './lib/tls.js'; +import { healthRoutes } from './routes/health.js'; +import { meSystemRoutes } from './routes/meSystem.js'; +import { wsRoutes } from './routes/ws.js'; + +/** + * Builds the Fastify application: a single server that speaks HTTPS for the + * REST API and WSS for the live feed, with OpenAPI docs served at /api/docs. + */ +export async function buildServer(): Promise { + const tls = config.tls.enabled + ? ensureTlsMaterial(config.tls.certPath, config.tls.keyPath) + : null; + + const baseOptions = { + logger: { level: process.env.LOG_LEVEL ?? 'info' }, + // ME systems can hold thousands of distinct items in one snapshot. + bodyLimit: 16 * 1024 * 1024, + }; + + const app = ( + tls + ? Fastify({ ...baseOptions, https: { key: tls.key, cert: tls.cert } }) + : Fastify(baseOptions) + ).withTypeProvider(); + + await app.register(cors, { origin: config.corsOrigin }); + + await app.register(swagger, { + openapi: { + info: { + title: 'Minecraft ComputerCraft ME API', + description: + 'Collects Minecraft ME system data pushed by a ComputerCraft computer ' + + 'and exposes it over HTTPS and WSS. The live data is also available as a ' + + 'websocket feed at `/api/ws/live`.', + version: '1.0.0', + }, + servers: [{ url: config.publicUrl }], + tags: [ + { name: 'me-system', description: 'ME system live and historic data' }, + { name: 'system', description: 'Service operations' }, + ], + }, + }); + await app.register(swaggerUi, { routePrefix: '/api/docs' }); + + await app.register(websocket); + + await app.register(healthRoutes); + await app.register(meSystemRoutes); + await app.register(wsRoutes); + + return app; +} diff --git a/src/services/meSystemService.ts b/src/services/meSystemService.ts new file mode 100644 index 0000000..a88c4d4 --- /dev/null +++ b/src/services/meSystemService.ts @@ -0,0 +1,52 @@ +import { config } from '../config.js'; +import { modFromName } from '../lib/modName.js'; +import * as repo from '../repositories/meSystem.js'; +import { wsHub } from '../ws/hub.js'; + +/** Raw item shape pushed by the ComputerCraft computer. */ +export interface RawUpdateItem { + name: string; + amount: number; + displayName?: string | null; +} + +export interface UpdateResult { + success: number; + historyWritten: boolean; +} + +// Unix timestamp (seconds) of the last history snapshot. +let lastHistoryUpdate = 0; + +/** + * Handles an ingest request: persists the live snapshot, writes a history + * snapshot at most every `historyIntervalSeconds`, and pushes the refreshed + * data to all connected websocket clients. + */ +export async function processUpdate(items: RawUpdateItem[]): Promise { + const normalized: repo.NormalizedItem[] = items.map((item) => ({ + name: item.name, + amount: item.amount, + displayName: item.displayName ?? null, + mod: modFromName(item.name), + })); + + await repo.upsertLive(normalized); + + const now = Math.floor(Date.now() / 1000); + let historyWritten = false; + if (now - lastHistoryUpdate >= config.historyIntervalSeconds) { + lastHistoryUpdate = now; + try { + await repo.snapshotHistory(normalized); + historyWritten = true; + } catch (err) { + // History is best-effort; the live data has already been committed. + console.error('Failed to write history snapshot:', err); + } + } + + await wsHub.broadcastUpdate(); + + return { success: normalized.length, historyWritten }; +} diff --git a/src/ws/hub.ts b/src/ws/hub.ts new file mode 100644 index 0000000..5ff4647 --- /dev/null +++ b/src/ws/hub.ts @@ -0,0 +1,87 @@ +import type { WebSocket } from '@fastify/websocket'; +import * as repo from '../repositories/meSystem.js'; + +/** Filter state held for every live websocket connection. */ +export interface LiveFilters { + search: string; + mod: string | null; + sort: repo.SortColumn; + order: repo.SortOrder; +} + +export function defaultFilters(): LiveFilters { + return { search: '', mod: null, sort: 'amount', order: 'desc' }; +} + +/** Normalizes untrusted filter input received over the websocket. */ +export function sanitizeFilters(input: unknown): LiveFilters { + const raw = (input ?? {}) as Record; + const sort = repo.SORT_COLUMNS.includes(raw.sort as repo.SortColumn) + ? (raw.sort as repo.SortColumn) + : 'amount'; + return { + search: typeof raw.search === 'string' ? raw.search.slice(0, 200) : '', + mod: typeof raw.mod === 'string' && raw.mod.length > 0 ? raw.mod : null, + sort, + order: raw.order === 'asc' ? 'asc' : 'desc', + }; +} + +interface Client { + socket: WebSocket; + filters: LiveFilters; +} + +type OutboundMessage = + | { type: 'snapshot' | 'update'; items: repo.LiveItem[]; mods: repo.ModInfo[]; filters: LiveFilters } + | { type: 'error'; message: string }; + +/** + * Registry of connected websocket clients. Each client keeps its own filter + * state, which is re-applied on every broadcast so the view stays filtered + * across data updates. + */ +class WsHub { + private readonly clients = new Set(); + + register(socket: WebSocket): Client { + const client: Client = { socket, filters: defaultFilters() }; + this.clients.add(client); + return client; + } + + unregister(client: Client): void { + this.clients.delete(client); + } + + get size(): number { + return this.clients.size; + } + + /** Sends the current snapshot to a single client using its own filters. */ + async sendSnapshot(client: Client, type: 'snapshot' | 'update' = 'snapshot'): Promise { + const [items, mods] = await Promise.all([repo.getLive(client.filters), repo.getMods()]); + this.send(client, { type, items, mods, filters: client.filters }); + } + + /** Pushes fresh data to every connected client, each with its own filters. */ + async broadcastUpdate(): Promise { + if (this.clients.size === 0) return; + const mods = await repo.getMods(); + await Promise.all( + [...this.clients].map(async (client) => { + const items = await repo.getLive(client.filters); + this.send(client, { type: 'update', items, mods, filters: client.filters }); + }), + ); + } + + private send(client: Client, payload: OutboundMessage): void { + if (client.socket.readyState === client.socket.OPEN) { + client.socket.send(JSON.stringify(payload)); + } + } +} + +export type { Client }; +export const wsHub = new WsHub(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3b18775 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"] +}