changes
This commit is contained in:
@@ -23,3 +23,15 @@ TLS_KEY_PATH=./certs/key.pem
|
||||
CORS_ORIGIN=*
|
||||
# Base URL advertised in the OpenAPI document.
|
||||
PUBLIC_URL=https://localhost:3000
|
||||
|
||||
# --- Icons -------------------------------------------------------------------
|
||||
# Directory containing the mod .jar files; scanned once on startup to extract
|
||||
# item / block textures into the icon cache.
|
||||
MODS_DIR=/mods
|
||||
# Optional path to the Minecraft client jar. Needed to extract vanilla
|
||||
# `minecraft:*` textures, since those live in the client jar and not in MODS_DIR.
|
||||
# Example: VANILLA_JAR=C:/Users/you/AppData/Roaming/.minecraft/versions/1.20.1/1.20.1.jar
|
||||
VANILLA_JAR=
|
||||
# Where the extracted PNGs are cached on disk. The cache survives restarts via
|
||||
# a manifest.json that tracks jar mtime+size.
|
||||
ICON_CACHE_DIR=/cache/icons
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||
docker login https://gitea.johannesbot.de -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
|
||||
- name: Build Image
|
||||
run: docker build -t gitea.johannesbot.de/johannesbot/mc-computer-craft-api-backend:latest .
|
||||
|
||||
- name: Push Image
|
||||
run: docker push gitea.johannesbot.de/johannesbot/mc-computer-craft-api-backend:latest
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create deployment directory
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
password: ${{ secrets.SSH_PASSWORD }}
|
||||
port: ${{ secrets.SSH_PORT || 22 }}
|
||||
script: mkdir -p /home/${{ secrets.SSH_USER }}/mc-computer-craft-api-backend
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
password: ${{ secrets.SSH_PASSWORD }}
|
||||
port: ${{ secrets.SSH_PORT || 22 }}
|
||||
script: |
|
||||
cd /home/${{ secrets.SSH_USER }}/mc-computer-craft-api
|
||||
mv docker-compose.prod.yml docker-compose.yml
|
||||
# Login as root/sudo to ensure we can pull
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | sudo docker login https://gitea.johannesbot.de -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
sudo docker compose pull
|
||||
sudo docker compose up -d --remove-orphans
|
||||
sudo docker image prune -f
|
||||
|
||||
|
||||
+4
-1
@@ -10,7 +10,10 @@ lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
.idea
|
||||
package-lock.json
|
||||
mods/
|
||||
cache/
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
--[[
|
||||
ME system uploader for ComputerCraft (CC: Tweaked).
|
||||
Storage 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.
|
||||
Works with both Advanced Peripherals bridges:
|
||||
* "meBridge" -> Applied Energistics 2 (listItems / item.amount)
|
||||
* "rsBridge" -> Refined Storage (getItems / item.count)
|
||||
|
||||
It periodically reads the network contents and POSTs them to the backend's
|
||||
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.
|
||||
@@ -14,25 +15,55 @@
|
||||
* terminate TLS with a proxy that uses a certificate CC trusts.
|
||||
]]
|
||||
|
||||
local API_URL = "http://your-server:3000/api/me-system/update"
|
||||
local API_URL = "https://192.168.2.150:3000/api/me-system/update"
|
||||
local INTERVAL = 10 -- seconds between uploads
|
||||
|
||||
-- Resolve the bridge peripheral and pick the right method for it.
|
||||
local bridge = peripheral.find("meBridge")
|
||||
local listFn
|
||||
if bridge then
|
||||
listFn = "listItems"
|
||||
else
|
||||
bridge = peripheral.find("rsBridge")
|
||||
if bridge then
|
||||
listFn = "getItems"
|
||||
end
|
||||
end
|
||||
if not bridge then
|
||||
error("No ME Bridge peripheral found")
|
||||
error("No meBridge or rsBridge peripheral found")
|
||||
end
|
||||
if type(bridge[listFn]) ~= "function" then
|
||||
error("Bridge peripheral does not expose " .. listFn .. "()")
|
||||
end
|
||||
|
||||
local function cleanDisplayName(raw)
|
||||
if type(raw) ~= "string" then return nil end
|
||||
-- ME/RS display names are wrapped in [brackets] — strip them.
|
||||
return (raw:gsub("[%[%]]", ""))
|
||||
end
|
||||
|
||||
-- Bridges report the same item id once per storage cell. Merge the rows here
|
||||
-- so the backend receives a clean snapshot.
|
||||
local function collect()
|
||||
local items = bridge.listItems()
|
||||
local payload = {}
|
||||
local items = bridge[listFn](bridge)
|
||||
local byName = {}
|
||||
local order = {}
|
||||
for _, item in ipairs(items) do
|
||||
payload[#payload + 1] = {
|
||||
local amount = item.amount or item.count or 0
|
||||
local entry = byName[item.name]
|
||||
if entry then
|
||||
entry.amount = entry.amount + amount
|
||||
else
|
||||
entry = {
|
||||
name = item.name,
|
||||
amount = item.amount or item.count or 0,
|
||||
displayName = item.displayName,
|
||||
amount = amount,
|
||||
displayName = cleanDisplayName(item.displayName),
|
||||
}
|
||||
byName[item.name] = entry
|
||||
order[#order + 1] = entry
|
||||
end
|
||||
return payload
|
||||
end
|
||||
return order
|
||||
end
|
||||
|
||||
local function upload(payload)
|
||||
@@ -48,7 +79,9 @@ local function upload(payload)
|
||||
response.close()
|
||||
end
|
||||
|
||||
print("ME uploader started. Target: " .. API_URL)
|
||||
print("Storage uploader started.")
|
||||
print(" Bridge: " .. listFn)
|
||||
print(" Target: " .. API_URL)
|
||||
while true do
|
||||
local ok, payload = pcall(collect)
|
||||
if ok then
|
||||
|
||||
+4
-1
@@ -10,7 +10,8 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:update-loop": "tsx test/update-loop.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
@@ -19,12 +20,14 @@
|
||||
"@fastify/type-provider-typebox": "^5.1.0",
|
||||
"@fastify/websocket": "^11.0.1",
|
||||
"@sinclair/typebox": "^0.34.9",
|
||||
"adm-zip": "^0.5.17",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.2.0",
|
||||
"pg": "^8.13.1",
|
||||
"selfsigned": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.11.10",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -15,6 +15,11 @@ function readBool(key: string, fallback: boolean): boolean {
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function readOptional(key: string): string | null {
|
||||
const value = process.env[key];
|
||||
return value === undefined || value === '' ? null : value;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
/** Interface the HTTP/WS server binds to. */
|
||||
host: readEnv('HOST', '0.0.0.0'),
|
||||
@@ -34,6 +39,12 @@ export const config = {
|
||||
corsOrigin: readEnv('CORS_ORIGIN', '*'),
|
||||
/** Public base URL advertised in the OpenAPI document. */
|
||||
publicUrl: readEnv('PUBLIC_URL', 'https://localhost:3000'),
|
||||
/** Directory containing mod `.jar` files; scanned for item textures on startup. */
|
||||
modsDir: readEnv('MODS_DIR', './mods'),
|
||||
/** Optional path to the Minecraft client jar to extract vanilla `minecraft:*` textures from. */
|
||||
vanillaJar: readOptional('VANILLA_JAR'),
|
||||
/** Directory where extracted item PNGs are cached on disk. */
|
||||
iconCacheDir: readEnv('ICON_CACHE_DIR', './cache/icons'),
|
||||
} as const;
|
||||
|
||||
export type AppConfig = typeof config;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { config } from './config.js';
|
||||
import { closePool } from './db/pool.js';
|
||||
import { runMigrations } from './db/migrate.js';
|
||||
import { iconCount, loadIcons } from './lib/iconRegistry.js';
|
||||
import { buildServer } from './server.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await runMigrations();
|
||||
await loadIcons();
|
||||
|
||||
const app = await buildServer();
|
||||
app.log.info(`Icon registry ready: ${iconCount()} item textures available`);
|
||||
await app.listen({ host: config.host, port: config.port });
|
||||
|
||||
const scheme = config.tls.enabled ? 'https' : 'http';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pool } from '../db/pool.js';
|
||||
import { iconUrl } from '../lib/iconRegistry.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -10,6 +11,7 @@ export interface LiveItem {
|
||||
mod: string;
|
||||
amount: number;
|
||||
updatedAt: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface ModInfo {
|
||||
@@ -59,12 +61,14 @@ function toIso(value: unknown): string {
|
||||
}
|
||||
|
||||
function rowToLiveItem(row: Record<string, unknown>): LiveItem {
|
||||
const name = row.name as string;
|
||||
return {
|
||||
name: row.name as string,
|
||||
name,
|
||||
displayName: (row.display_name as string | null) ?? null,
|
||||
mod: row.mod as string,
|
||||
amount: Number(row.amount),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
icon: iconUrl(name),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ export const LiveItemSchema = Type.Object({
|
||||
mod: Type.String(),
|
||||
amount: Type.Integer(),
|
||||
updatedAt: Type.String({ format: 'date-time' }),
|
||||
icon: Type.Union([Type.String(), Type.Null()], {
|
||||
description: 'URL to a 64×64 PNG, or null when no texture is available.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const ModInfoSchema = Type.Object({
|
||||
|
||||
+5
-8
@@ -7,6 +7,7 @@ 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 { iconRoutes } from './routes/icon.js';
|
||||
import { meSystemRoutes } from './routes/meSystem.js';
|
||||
import { wsRoutes } from './routes/ws.js';
|
||||
|
||||
@@ -19,17 +20,12 @@ export async function buildServer(): Promise<FastifyInstance> {
|
||||
? ensureTlsMaterial(config.tls.certPath, config.tls.keyPath)
|
||||
: null;
|
||||
|
||||
const baseOptions = {
|
||||
const app = Fastify({
|
||||
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>();
|
||||
...(tls ? { https: { key: tls.key, cert: tls.cert } } : {}),
|
||||
}).withTypeProvider<TypeBoxTypeProvider>();
|
||||
|
||||
await app.register(cors, { origin: config.corsOrigin });
|
||||
|
||||
@@ -56,6 +52,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(meSystemRoutes);
|
||||
await app.register(iconRoutes);
|
||||
await app.register(wsRoutes);
|
||||
|
||||
return app;
|
||||
|
||||
@@ -24,12 +24,21 @@ let lastHistoryUpdate = 0;
|
||||
* data to all connected websocket clients.
|
||||
*/
|
||||
export async function processUpdate(items: RawUpdateItem[]): Promise<UpdateResult> {
|
||||
const normalized: repo.NormalizedItem[] = items.map((item) => ({
|
||||
const seen = new Map<string, repo.NormalizedItem>();
|
||||
for (const item of items) {
|
||||
const existing = seen.get(item.name);
|
||||
if (existing) {
|
||||
existing.amount += item.amount;
|
||||
} else {
|
||||
seen.set(item.name, {
|
||||
name: item.name,
|
||||
amount: item.amount,
|
||||
displayName: item.displayName ?? null,
|
||||
mod: modFromName(item.name),
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
const normalized = [...seen.values()];
|
||||
|
||||
await repo.upsertLive(normalized);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user