init
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
certs
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -215,3 +215,10 @@ fabric.properties
|
|||||||
# Android studio 3.1+ serialized cache file
|
# Android studio 3.1+ serialized cache file
|
||||||
.idea/caches/build_file_checksums.ser
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
certs/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
+18
@@ -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"]
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
`;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user