CI/CD + deploy: Gitea Actions -> registry -> Dockge -> NPM (rcai.brasse-pc.eu)
Some checks failed
build-and-push / build (push) Has been cancelled

Server:
- serverar byggd web-frontend statiskt + SPA-fallback (WEB_DIST)
- host-mountad persistent katalog via DATA_DIR (/srv/docker/agent-helm -> /app/data),
  förberedd för framtida db/auth enligt övriga appars konvention
- esbuild-bundle -> dist/server.cjs (en fil, inga node_modules i runtime)
Web:
- WS-URL defaultar till samma origin i prod (wss://rcai.brasse-pc.eu), localhost i dev

Deploy-artefakter:
- Dockerfile: multi-stage, bygger web + bundlar server; --filter utesluter daemon
  (ingen node-pty-kompilering); kör som uid 1000 (= brasse på host)
- .gitea/workflows/build.yaml: push master -> bygg arm64 -> push localhost:5000
- deploy/act-runner.{compose,config}.yaml: Gitea Actions-runner i Docker (docker.sock,
  job-containrar får värdens docker for build/push)
- deploy/agent-helm.compose.yaml: Dockge-stack (srv_default, expose 8787, data-mount,
  x-dockge.urls)
- deploy/DEPLOY.md: full runbook (runner, bygge, stack, NPM, uppdatering, felsökning)

Verifierat lokalt: typecheck rent, server-bundle serverar UI+health+assets+SPA-fallback,
data-dir skapas. Docker-bygget validerat så långt daemonen är nere (filter bekräftat).
This commit is contained in:
2026-06-28 16:03:14 +02:00
parent 09f1168c17
commit 062e02b417
11 changed files with 358 additions and 1 deletions

View File

@@ -7,6 +7,7 @@
"dev": "tsx --env-file-if-exists=../../.env watch src/index.ts",
"start": "tsx --env-file-if-exists=../../.env src/index.ts",
"build": "tsc --noEmit -p tsconfig.json",
"bundle": "esbuild src/index.ts --bundle --platform=node --target=node22 --outfile=dist/server.cjs",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
@@ -18,6 +19,7 @@
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.5.12",
"esbuild": "^0.25.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}

View File

@@ -10,6 +10,9 @@
* - Routing: web:input -> rätt daemons pty. daemon:output -> alla prenumeranter.
*/
import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import fs from "node:fs";
import path from "node:path";
import { WebSocketServer, WebSocket } from "ws";
// @xterm/headless och addon-serialize är CommonJS — default-importera värdet och
// destrukturera; ta typen via `import type` (raderas i runtime).
@@ -37,6 +40,11 @@ if (!TOKEN) {
process.exit(1);
}
// Persistent katalog (host-mountad på Pi5: /srv/docker/agent-helm -> /app/data).
// Här hamnar framtida db/auth-state. Skapas vid start om den är satt.
const DATA_DIR = process.env.DATA_DIR ?? "";
if (DATA_DIR) fs.mkdirSync(DATA_DIR, { recursive: true });
interface Session {
meta: SessionMeta;
daemon: WebSocket | null;
@@ -180,12 +188,58 @@ function handleClose(ws: WebSocket): void {
}
}
const WEB_DIST = process.env.WEB_DIST ?? "";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".webmanifest": "application/manifest+json",
".woff2": "font/woff2",
".map": "application/json",
};
/** Serverar den byggda web-frontenden (om WEB_DIST är satt) med SPA-fallback. */
function serveStatic(req: IncomingMessage, res: ServerResponse): boolean {
if (!WEB_DIST) return false;
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
const indexHtml = path.join(WEB_DIST, "index.html");
let filePath = path.normalize(path.join(WEB_DIST, urlPath));
// Path-traversal-skydd + SPA: "/" och rutter utan filändelse -> index.html
if (!filePath.startsWith(WEB_DIST) || urlPath === "/" || !path.extname(filePath)) {
filePath = indexHtml;
}
fs.readFile(filePath, (err, buf) => {
if (err) {
fs.readFile(indexHtml, (err2, idx) => {
if (err2) {
res.writeHead(404);
res.end("not found");
} else {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(idx);
}
});
return;
}
res.writeHead(200, { "content-type": MIME[path.extname(filePath)] ?? "application/octet-stream" });
res.end(buf);
});
return true;
}
const server = http.createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
return;
}
if (serveStatic(req, res)) return;
res.writeHead(404);
res.end();
});
@@ -208,4 +262,6 @@ wss.on("connection", (ws) => {
server.listen(PORT, () => {
console.log(`[server] lyssnar på :${PORT} (WebSocket + /health)`);
if (WEB_DIST) console.log(`[server] serverar web-UI från ${WEB_DIST}`);
if (DATA_DIR) console.log(`[server] data-dir: ${DATA_DIR}`);
});