Phase3
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:17:26 +02:00
parent c4077d4673
commit 9b061c3ee7
92 changed files with 4128 additions and 146 deletions

View File

@@ -0,0 +1,86 @@
import { getApiUrl } from "../env";
import { apiFetch } from "./auth";
export interface ConsoleTokenResponse {
token: string;
expiresAt: string;
}
export interface ConsoleWebSocketHandlers {
onOpen?: () => void;
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
onLine?: (line: string, stream?: string) => void;
}
interface ConsoleLogMessage {
type?: string;
line?: string;
stream?: string;
message?: string;
}
function getConsoleWebSocketUrl(serverId: string, token: string): string {
const apiUrl = new URL(getApiUrl());
apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
const basePath = apiUrl.pathname.replace(/\/$/, "");
apiUrl.pathname = `${basePath}/servers/${serverId}/console/ws`;
apiUrl.search = new URLSearchParams({ token }).toString();
return apiUrl.toString();
}
export async function getConsoleToken(serverId: string): Promise<ConsoleTokenResponse> {
return apiFetch<ConsoleTokenResponse>(`/servers/${serverId}/console/token`);
}
export function createConsoleWebSocket(
serverId: string,
token: string,
handlers: ConsoleWebSocketHandlers,
): WebSocket {
const socket = new WebSocket(getConsoleWebSocketUrl(serverId, token));
socket.addEventListener("open", () => {
handlers.onOpen?.();
});
socket.addEventListener("close", (event) => {
handlers.onClose?.(event);
});
socket.addEventListener("error", (event) => {
handlers.onError?.(event);
});
socket.addEventListener("message", (event) => {
const raw = typeof event.data === "string" ? event.data : "";
try {
const parsed = JSON.parse(raw) as ConsoleLogMessage;
if (parsed.type === "log" && parsed.line) {
handlers.onLine?.(parsed.line, parsed.stream);
return;
}
if (parsed.line) {
handlers.onLine?.(parsed.line, parsed.stream);
return;
}
if (parsed.message) {
handlers.onLine?.(parsed.message, parsed.stream);
return;
}
} catch {
// Plain text log line
}
if (raw) {
handlers.onLine?.(raw);
}
});
return socket;
}
export function sendConsoleCommand(socket: WebSocket, command: string): void {
socket.send(JSON.stringify({ command }));
}

View File

@@ -0,0 +1,61 @@
import { apiFetch } from "./auth";
export interface FileEntry {
name: string;
path: string;
isDir: boolean;
size: number;
modifiedAt?: string;
}
export interface ListFilesResponse {
path: string;
entries: FileEntry[];
}
export interface FileContentResponse {
path: string;
content: string;
truncated?: boolean;
version?: number;
}
export interface WriteFilePayload {
path: string;
content: string;
version?: number;
}
export async function listFiles(
serverId: string,
path = "",
): Promise<ListFilesResponse> {
const params = new URLSearchParams();
if (path) {
params.set("path", path);
}
const query = params.toString();
return apiFetch<ListFilesResponse>(
`/servers/${serverId}/files${query ? `?${query}` : ""}`,
);
}
export async function readFile(
serverId: string,
path: string,
): Promise<FileContentResponse> {
const params = new URLSearchParams({ path });
return apiFetch<FileContentResponse>(
`/servers/${serverId}/files/content?${params.toString()}`,
);
}
export async function writeFile(
serverId: string,
payload: WriteFilePayload,
): Promise<FileContentResponse> {
return apiFetch<FileContentResponse>(`/servers/${serverId}/files/content`, {
method: "PUT",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,17 @@
import { apiFetch } from "./auth";
export interface OnlinePlayer {
name: string;
uuid?: string;
ping?: number;
}
export interface PlayerListResponse {
online: OnlinePlayer[];
maxPlayers: number;
whitelistCount: number;
}
export async function getPlayers(serverId: string): Promise<PlayerListResponse> {
return apiFetch<PlayerListResponse>(`/servers/${serverId}/players`);
}

View File

@@ -0,0 +1,36 @@
import { apiFetch } from "./auth";
export type GameMode = "survival" | "creative" | "adventure" | "spectator";
export type Difficulty = "peaceful" | "easy" | "normal" | "hard";
export interface ServerProperties {
motd: string;
gamemode: GameMode;
difficulty: Difficulty;
maxPlayers: number;
pvp: boolean;
whitelist: boolean;
hardcore?: boolean;
onlineMode?: boolean;
enableCommandBlock?: boolean;
viewDistance?: number;
simulationDistance?: number;
spawnProtection?: number;
allowFlight?: boolean;
}
export type UpdateServerPropertiesPayload = Partial<ServerProperties>;
export async function getProperties(serverId: string): Promise<ServerProperties> {
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`);
}
export async function updateProperties(
serverId: string,
payload: UpdateServerPropertiesPayload,
): Promise<ServerProperties> {
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,69 @@
const ANSI_PATTERN =
/\u001b\[[0-9;]*m|\u001b\]8;;.*?\u001b\\|\u001b\][0-9];.*?\u0007/g;
const ANSI_COLOR_MAP: Record<string, string> = {
"30": "text-zinc-400",
"31": "text-red-400",
"32": "text-green-400",
"33": "text-yellow-400",
"34": "text-blue-400",
"35": "text-purple-400",
"36": "text-cyan-400",
"37": "text-zinc-200",
"90": "text-zinc-500",
"91": "text-red-300",
"92": "text-green-300",
"93": "text-yellow-300",
"94": "text-blue-300",
"95": "text-purple-300",
"96": "text-cyan-300",
"97": "text-zinc-100",
};
export function stripAnsi(text: string): string {
return text.replace(ANSI_PATTERN, "");
}
export interface AnsiSegment {
text: string;
className?: string;
}
export function parseAnsiLine(line: string): AnsiSegment[] {
const segments: AnsiSegment[] = [];
let currentClass: string | undefined;
let buffer = "";
const regex = /\u001b\[([0-9;]*)m/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(line)) !== null) {
if (match.index > lastIndex) {
const text = line.slice(lastIndex, match.index);
if (text) {
segments.push({ text, className: currentClass });
}
}
const codes = match[1].split(";").filter(Boolean);
if (codes.length === 0 || codes.includes("0")) {
currentClass = undefined;
} else {
const colorCode = codes.find((code) => ANSI_COLOR_MAP[code]);
currentClass = colorCode ? ANSI_COLOR_MAP[colorCode] : currentClass;
}
lastIndex = regex.lastIndex;
}
buffer = line.slice(lastIndex).replace(ANSI_PATTERN, "");
if (buffer) {
segments.push({ text: buffer, className: currentClass });
}
if (segments.length === 0) {
return [{ text: stripAnsi(line) }];
}
return segments;
}