This commit is contained in:
2026-05-26 12:26:05 +02:00
parent db83c00ac7
commit 7de6864f70
24 changed files with 1156 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
certs
.env
.git
*.log
+25
View File
@@ -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
+7
View File
@@ -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
+18
View File
@@ -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"]
+99 -1
View File
@@ -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 <https://localhost:3000/api/docs> 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 |
+60
View File
@@ -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
+40
View File
@@ -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:
+33
View File
@@ -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"
}
}
+39
View File
@@ -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;
+7
View File
@@ -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<void> {
await pool.query(SCHEMA_SQL);
}
+16
View File
@@ -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<void> {
await pool.end();
}
+28
View File
@@ -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);
`;
+33
View File
@@ -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<void> {
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<void> => {
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);
});
+12
View File
@@ -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);
}
+41
View File
@@ -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) };
}
+206
View File
@@ -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<SortColumn, string> = {
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<string, unknown>): 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<void> {
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<void> {
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<LiveItem[]> {
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<LiveItem | null> {
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<ModInfo[]> {
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<HistoryPoint[]> {
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();
}
+21
View File
@@ -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,
}),
);
};
+100
View File
@@ -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 };
},
);
};
+38
View File
@@ -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<void> {
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));
});
}
+107
View File
@@ -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(),
});
+62
View File
@@ -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<FastifyInstance> {
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<TypeBoxTypeProvider>();
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;
}
+52
View File
@@ -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<UpdateResult> {
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 };
}
+87
View File
@@ -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<string, unknown>;
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<Client>();
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<void> {
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<void> {
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();
+19
View File
@@ -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/**/*"]
}