changes
This commit is contained in:
@@ -23,3 +23,15 @@ TLS_KEY_PATH=./certs/key.pem
|
|||||||
CORS_ORIGIN=*
|
CORS_ORIGIN=*
|
||||||
# Base URL advertised in the OpenAPI document.
|
# Base URL advertised in the OpenAPI document.
|
||||||
PUBLIC_URL=https://localhost:3000
|
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)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
.idea
|
||||||
|
package-lock.json
|
||||||
|
mods/
|
||||||
|
cache/
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.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
|
Works with both Advanced Peripherals bridges:
|
||||||
computer and wired to an Applied Energistics 2 / Refined Storage network.
|
* "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.
|
/api/me-system/update endpoint.
|
||||||
|
|
||||||
NOTE: the backend serves HTTPS with a self-signed certificate by default.
|
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.
|
* 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
|
local INTERVAL = 10 -- seconds between uploads
|
||||||
|
|
||||||
|
-- Resolve the bridge peripheral and pick the right method for it.
|
||||||
local bridge = peripheral.find("meBridge")
|
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
|
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
|
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 function collect()
|
||||||
local items = bridge.listItems()
|
local items = bridge[listFn](bridge)
|
||||||
local payload = {}
|
local byName = {}
|
||||||
|
local order = {}
|
||||||
for _, item in ipairs(items) do
|
for _, item in ipairs(items) do
|
||||||
payload[#payload + 1] = {
|
local amount = item.amount or item.count or 0
|
||||||
name = item.name,
|
local entry = byName[item.name]
|
||||||
amount = item.amount or item.count or 0,
|
if entry then
|
||||||
displayName = item.displayName,
|
entry.amount = entry.amount + amount
|
||||||
}
|
else
|
||||||
|
entry = {
|
||||||
|
name = item.name,
|
||||||
|
amount = amount,
|
||||||
|
displayName = cleanDisplayName(item.displayName),
|
||||||
|
}
|
||||||
|
byName[item.name] = entry
|
||||||
|
order[#order + 1] = entry
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return payload
|
return order
|
||||||
end
|
end
|
||||||
|
|
||||||
local function upload(payload)
|
local function upload(payload)
|
||||||
@@ -48,7 +79,9 @@ local function upload(payload)
|
|||||||
response.close()
|
response.close()
|
||||||
end
|
end
|
||||||
|
|
||||||
print("ME uploader started. Target: " .. API_URL)
|
print("Storage uploader started.")
|
||||||
|
print(" Bridge: " .. listFn)
|
||||||
|
print(" Target: " .. API_URL)
|
||||||
while true do
|
while true do
|
||||||
local ok, payload = pcall(collect)
|
local ok, payload = pcall(collect)
|
||||||
if ok then
|
if ok then
|
||||||
|
|||||||
+4
-1
@@ -10,7 +10,8 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test:update-loop": "tsx test/update-loop.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
@@ -19,12 +20,14 @@
|
|||||||
"@fastify/type-provider-typebox": "^5.1.0",
|
"@fastify/type-provider-typebox": "^5.1.0",
|
||||||
"@fastify/websocket": "^11.0.1",
|
"@fastify/websocket": "^11.0.1",
|
||||||
"@sinclair/typebox": "^0.34.9",
|
"@sinclair/typebox": "^0.34.9",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"selfsigned": "^2.4.1"
|
"selfsigned": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ function readBool(key: string, fallback: boolean): boolean {
|
|||||||
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
|
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 = {
|
export const config = {
|
||||||
/** Interface the HTTP/WS server binds to. */
|
/** Interface the HTTP/WS server binds to. */
|
||||||
host: readEnv('HOST', '0.0.0.0'),
|
host: readEnv('HOST', '0.0.0.0'),
|
||||||
@@ -34,6 +39,12 @@ export const config = {
|
|||||||
corsOrigin: readEnv('CORS_ORIGIN', '*'),
|
corsOrigin: readEnv('CORS_ORIGIN', '*'),
|
||||||
/** Public base URL advertised in the OpenAPI document. */
|
/** Public base URL advertised in the OpenAPI document. */
|
||||||
publicUrl: readEnv('PUBLIC_URL', 'https://localhost:3000'),
|
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;
|
} as const;
|
||||||
|
|
||||||
export type AppConfig = typeof config;
|
export type AppConfig = typeof config;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { closePool } from './db/pool.js';
|
import { closePool } from './db/pool.js';
|
||||||
import { runMigrations } from './db/migrate.js';
|
import { runMigrations } from './db/migrate.js';
|
||||||
|
import { iconCount, loadIcons } from './lib/iconRegistry.js';
|
||||||
import { buildServer } from './server.js';
|
import { buildServer } from './server.js';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
await loadIcons();
|
||||||
|
|
||||||
const app = await buildServer();
|
const app = await buildServer();
|
||||||
|
app.log.info(`Icon registry ready: ${iconCount()} item textures available`);
|
||||||
await app.listen({ host: config.host, port: config.port });
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
|
||||||
const scheme = config.tls.enabled ? 'https' : 'http';
|
const scheme = config.tls.enabled ? 'https' : 'http';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pool } from '../db/pool.js';
|
import { pool } from '../db/pool.js';
|
||||||
|
import { iconUrl } from '../lib/iconRegistry.js';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -10,6 +11,7 @@ export interface LiveItem {
|
|||||||
mod: string;
|
mod: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
icon: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModInfo {
|
export interface ModInfo {
|
||||||
@@ -59,12 +61,14 @@ function toIso(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rowToLiveItem(row: Record<string, unknown>): LiveItem {
|
function rowToLiveItem(row: Record<string, unknown>): LiveItem {
|
||||||
|
const name = row.name as string;
|
||||||
return {
|
return {
|
||||||
name: row.name as string,
|
name,
|
||||||
displayName: (row.display_name as string | null) ?? null,
|
displayName: (row.display_name as string | null) ?? null,
|
||||||
mod: row.mod as string,
|
mod: row.mod as string,
|
||||||
amount: Number(row.amount),
|
amount: Number(row.amount),
|
||||||
updatedAt: toIso(row.updated_at),
|
updatedAt: toIso(row.updated_at),
|
||||||
|
icon: iconUrl(name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export const LiveItemSchema = Type.Object({
|
|||||||
mod: Type.String(),
|
mod: Type.String(),
|
||||||
amount: Type.Integer(),
|
amount: Type.Integer(),
|
||||||
updatedAt: Type.String({ format: 'date-time' }),
|
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({
|
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 { config } from './config.js';
|
||||||
import { ensureTlsMaterial } from './lib/tls.js';
|
import { ensureTlsMaterial } from './lib/tls.js';
|
||||||
import { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
|
import { iconRoutes } from './routes/icon.js';
|
||||||
import { meSystemRoutes } from './routes/meSystem.js';
|
import { meSystemRoutes } from './routes/meSystem.js';
|
||||||
import { wsRoutes } from './routes/ws.js';
|
import { wsRoutes } from './routes/ws.js';
|
||||||
|
|
||||||
@@ -19,17 +20,12 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|||||||
? ensureTlsMaterial(config.tls.certPath, config.tls.keyPath)
|
? ensureTlsMaterial(config.tls.certPath, config.tls.keyPath)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const baseOptions = {
|
const app = Fastify({
|
||||||
logger: { level: process.env.LOG_LEVEL ?? 'info' },
|
logger: { level: process.env.LOG_LEVEL ?? 'info' },
|
||||||
// ME systems can hold thousands of distinct items in one snapshot.
|
// ME systems can hold thousands of distinct items in one snapshot.
|
||||||
bodyLimit: 16 * 1024 * 1024,
|
bodyLimit: 16 * 1024 * 1024,
|
||||||
};
|
...(tls ? { https: { key: tls.key, cert: tls.cert } } : {}),
|
||||||
|
}).withTypeProvider<TypeBoxTypeProvider>();
|
||||||
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(cors, { origin: config.corsOrigin });
|
||||||
|
|
||||||
@@ -56,6 +52,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|||||||
|
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(meSystemRoutes);
|
await app.register(meSystemRoutes);
|
||||||
|
await app.register(iconRoutes);
|
||||||
await app.register(wsRoutes);
|
await app.register(wsRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -24,12 +24,21 @@ let lastHistoryUpdate = 0;
|
|||||||
* data to all connected websocket clients.
|
* data to all connected websocket clients.
|
||||||
*/
|
*/
|
||||||
export async function processUpdate(items: RawUpdateItem[]): Promise<UpdateResult> {
|
export async function processUpdate(items: RawUpdateItem[]): Promise<UpdateResult> {
|
||||||
const normalized: repo.NormalizedItem[] = items.map((item) => ({
|
const seen = new Map<string, repo.NormalizedItem>();
|
||||||
name: item.name,
|
for (const item of items) {
|
||||||
amount: item.amount,
|
const existing = seen.get(item.name);
|
||||||
displayName: item.displayName ?? null,
|
if (existing) {
|
||||||
mod: modFromName(item.name),
|
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);
|
await repo.upsertLive(normalized);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user