server state and stdio streaming

This commit is contained in:
Emmaline Autumn 2023-10-03 02:57:35 -06:00
parent dc4c8efeb2
commit 4a4563ba85
25 changed files with 502 additions and 80 deletions

1
.gitignore vendored
View File

@ -13,6 +13,7 @@ _fresh/
fabric/ fabric/
forge/ forge/
vanilla/ vanilla/
server/
# Config file for MCGRIZZ # Config file for MCGRIZZ
mcgrizz.json mcgrizz.json

View File

@ -42,7 +42,7 @@ export function Button(
return ( return (
<button <button
{...props} {...props}
disabled={(!IS_BROWSER && !props.href) || props.disabled} disabled={(!IS_BROWSER && !props.href && props.type !== 'submit') || props.disabled}
class={classes} class={classes}
/> />
); );

10
components/Loader.tsx Normal file
View File

@ -0,0 +1,10 @@
export function Loader() {
return (
<div class="grid grid-cols-2 grid-rows-2 w-20 h-20 loader m-auto">
<div class="loader-ball w-5 h-5 rounded-full"></div>
<div class="loader-ball w-5 h-5 rounded-full" style={{'--loader-delay': '-1s'}}></div>
<div class="loader-ball w-5 h-5 rounded-full" style={{'--loader-delay': '-1.7s'}}></div>
<div class="loader-ball w-5 h-5 rounded-full" style={{'--loader-delay': '-2.6s'}}></div>
</div>
)
}

View File

@ -30,7 +30,9 @@
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.1", "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.1",
"@preact/signals": "https://esm.sh/*@preact/signals@1.1.3", "@preact/signals": "https://esm.sh/*@preact/signals@1.1.3",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.2.3", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.2.3",
"$std/": "https://deno.land/std@0.193.0/" "$std/": "https://deno.land/std@0.193.0/",
"puppet": "https://deno.land/x/sockpuppet@0.6.0/mod.ts",
"puppet/client": "https://deno.land/x/sockpuppet@0.6.0/client/mod.ts"
}, },
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",

View File

@ -4,21 +4,31 @@
import * as $0 from "./routes/_404.tsx"; import * as $0 from "./routes/_404.tsx";
import * as $1 from "./routes/_app.tsx"; import * as $1 from "./routes/_app.tsx";
import * as $2 from "./routes/api/joke.ts"; import * as $2 from "./routes/api/fabric/index.ts";
import * as $3 from "./routes/index.tsx"; import * as $3 from "./routes/api/terminal.ts";
import * as $4 from "./routes/setup/index.tsx"; import * as $4 from "./routes/index.tsx";
import * as $$0 from "./islands/Counter.tsx"; import * as $5 from "./routes/setup/eula.tsx";
import * as $6 from "./routes/setup/fabric.tsx";
import * as $7 from "./routes/setup/index.tsx";
import * as $8 from "./routes/setup/start.tsx";
import * as $$0 from "./islands/fabricVersions.tsx";
import * as $$1 from "./islands/terminal.tsx";
const manifest = { const manifest = {
routes: { routes: {
"./routes/_404.tsx": $0, "./routes/_404.tsx": $0,
"./routes/_app.tsx": $1, "./routes/_app.tsx": $1,
"./routes/api/joke.ts": $2, "./routes/api/fabric/index.ts": $2,
"./routes/index.tsx": $3, "./routes/api/terminal.ts": $3,
"./routes/setup/index.tsx": $4, "./routes/index.tsx": $4,
"./routes/setup/eula.tsx": $5,
"./routes/setup/fabric.tsx": $6,
"./routes/setup/index.tsx": $7,
"./routes/setup/start.tsx": $8,
}, },
islands: { islands: {
"./islands/Counter.tsx": $$0, "./islands/fabricVersions.tsx": $$0,
"./islands/terminal.tsx": $$1,
}, },
baseUrl: import.meta.url, baseUrl: import.meta.url,
}; };

View File

@ -1,16 +0,0 @@
import type { Signal } from "@preact/signals";
import { Button } from "../components/Button.tsx";
interface CounterProps {
count: Signal<number>;
}
export default function Counter(props: CounterProps) {
return (
<div class="flex gap-8 py-6">
<Button color="fire" onClick={() => props.count.value -= 1}>-1</Button>
<p class="text-3xl">{props.count}</p>
<Button color="sky" onClick={() => props.count.value += 1}>+1</Button>
</div>
);
}

View File

@ -0,0 +1,79 @@
import { useEffect, useState } from "preact/hooks";
import { FabricGame, FabricInstaller, FabricLoader } from "../types/fabric.ts";
import { Loader } from "../components/Loader.tsx";
import { Button } from "../components/Button.tsx";
export function FabricVersions() {
const [game, setGame] = useState<FabricGame[]>([]);
const [installer, setInstaller] = useState<FabricInstaller[]>([]);
const [loader, setLoader] = useState<FabricLoader[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [includeSnapshots, setIncludeSnapshots] = useState(false);
useEffect(() => {
let poll: number;
if (isLoading) {
poll = setInterval(async () => {
const res = await fetch("/api/fabric");
if (res.status !== 200) return;
const json: {
gameVersions: FabricGame[];
installerVersions: FabricInstaller[];
loaderVersions: FabricLoader[];
} = await res.json();
setGame(json.gameVersions);
setInstaller(json.installerVersions);
setLoader(json.loaderVersions);
setIsLoading(false);
}, 500);
}
return () => clearInterval(poll);
}, [isLoading]);
return isLoading ? <Loader /> : (
<form action="/api/fabric" method="POST">
<div class="grid grid-cols-3 gap-8">
<div>
<label class="block" htmlFor="game">Game Version</label>
<select name="game">
{game.filter((g) => includeSnapshots || g.stable).map((g) => (
<option>{g.version}</option>
))}
</select>
<br />
<label>
<input
type="checkbox"
checked={includeSnapshots}
onChange={() => setIncludeSnapshots(!includeSnapshots)}
/>{" "}
Include Snapshots?
</label>
</div>
<div>
<label class="block" htmlFor="loader">Loader Version</label>
<select name="loader">
{loader.map((g) => <option>{g.version}</option>)}
</select>
<small class="block">
You probably don't need to change this one.
</small>
</div>
<div>
<label class="block" htmlFor="installer">Installer Version</label>
<select name="installer">
{installer.map((g) => <option>{g.version}</option>)}
</select>
<small class="block">
You probably don't need to change this one.
</small>
</div>
</div>
<div class="w-full">
<Button class="ml-auto text-right text-lg font-pixel block mt-8" type="submit">Let's Go!</Button>
</div>
</form>
);
}

48
islands/terminal.tsx Normal file
View File

@ -0,0 +1,48 @@
import { IS_BROWSER } from "$fresh/runtime.ts";
import { useEffect, useRef, useState } from "preact/hooks";
import { Sockpuppet } from "puppet/client";
export function Terminal(props: { channelId: string }) {
const puppet = useRef(new Sockpuppet("ws://sockpuppet.cyborggrizzly.com"));
const [lines, setLines] = useState<string[]>([]);
const divRef = useRef<HTMLDivElement>(null);
const [command, setCommand] = useState("");
useEffect(() => {
if (!IS_BROWSER) return;
puppet.current.joinChannel(props.channelId, (line) => {
setLines((l) => [...l, line]);
});
}, []);
useEffect(() => {
if (divRef.current) {
divRef.current.scrollTop = divRef.current.scrollHeight;
}
}, [lines]);
const sendCommand = (e: Event) => {
e.preventDefault();
puppet.current.getChannel(props.channelId)?.send(command);
setCommand("");
};
return (
<div>
<div ref={divRef} class="flex flex-col h-[600px] w-full overflow-auto">
{lines.map((l) => <div class="font-mono w-full">{l}</div>)}
</div>
<div>
<form onSubmit={sendCommand}>
<input
type="text"
class="w-full bg-smoke-600 text-white"
value={command}
onInput={(e) => setCommand((e.target as HTMLInputElement).value)}
/>
</form>
</div>
</div>
);
}

View File

@ -9,7 +9,7 @@ export default function Error404() {
<div class="container w-full"> <div class="container w-full">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center"> <div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<img <img
class="my-6" class="my-6 rounded-3xl"
src="/bearcam.gif" src="/bearcam.gif"
alt="a gif of a bear playing with the camera" alt="a gif of a bear playing with the camera"
/> />

View File

@ -7,7 +7,7 @@ export default function App({ Component }: AppProps) {
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="cyborggrizzly.svg" type="image/svg" /> <link rel="shortcut icon" href="/cyborggrizzly.svg" type="image/svg" />
<title>MC GRIZZ</title> <title>MC GRIZZ</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link <link
@ -24,9 +24,9 @@ export default function App({ Component }: AppProps) {
</head> </head>
<body> <body>
<div class="flex h-[100vh]"> <div class="flex h-[100vh]">
<div class="h-full min-w-[400px] bg-licorice overflow-y-auto border-r-2 p-4 dark:border-sky-950 border-sky text-white flex flex-col"> <div class="relative h-full min-w-[400px] bg-licorice overflow-y-auto border-r-2 p-4 dark:border-sky-950 border-sky text-white flex flex-col">
<div class="flex items-center justify-center p-4 gap-4 mb-8 rounded-3xl bg-smoke-900 border border-sky-950"> <div class="flex items-center justify-center p-4 gap-4 mb-8 rounded-3xl bg-smoke-900 border border-sky-950">
<img src="cyborggrizzly.svg" alt="" height={100} width={100} /> <img src="/cyborggrizzly.svg" alt="" height={100} width={100} />
<div> <div>
<h1 class="text-4xl font-pixel">MC Grizz</h1> <h1 class="text-4xl font-pixel">MC Grizz</h1>
<hr class="color-sky" /> <hr class="color-sky" />
@ -36,11 +36,11 @@ export default function App({ Component }: AppProps) {
<Nav /> <Nav />
<small class="mt-auto text-center"> <div class="w-full text-center absolute bottom-4 left-0 whitespace-nowrap">
Made with love by Emma@Cyborggrizzly 🏳 <small>Made with love by Emma@Cyborggrizzly 🏳</small>
</small> </div>
</div> </div>
<div class="max-h-full flex-1"> <div class="max-h-full flex-1 overflow-auto">
<Component /> <Component />
</div> </div>
</div> </div>

View File

@ -1,21 +0,0 @@
import { HandlerContext } from "$fresh/server.ts";
// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/
const JOKES = [
"Why do Java developers often wear glasses? They can't C#.",
"A SQL query walks into a bar, goes up to two tables and says “can I join you?”",
"Wasn't hard to crack Forrest Gump's password. 1forrest1.",
"I love pressing the F5 key. It's refreshing.",
"Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”",
"There are 10 types of people in the world. Those who understand binary and those who don't.",
"Why are assembly programmers often wet? They work below C level.",
"My favourite computer based band is the Black IPs.",
"What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.",
"An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.",
];
export const handler = (_req: Request, _ctx: HandlerContext): Response => {
const randomIndex = Math.floor(Math.random() * JOKES.length);
const body = JOKES[randomIndex];
return new Response(body);
};

1
routes/api/terminal.ts Normal file
View File

@ -0,0 +1 @@
// const

48
routes/setup/eula.tsx Normal file
View File

@ -0,0 +1,48 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { Button } from "../../components/Button.tsx";
import { Content } from "../../components/Content.tsx";
import { SERVER_STATE } from "../../state/serverState.ts";
export const handler: Handlers = {
async POST(req, _ctx) {
const url = new URL(req.url);
SERVER_STATE.acceptEULA();
return Response.redirect(url.origin + '/setup/start');
}
};
export default async function EULA({url}: PageProps) {
// TODO: this does not respect instances and needs to once they are supported
const eulaText = await Deno.readTextFileSync("./server/eula.txt");
return (
<div class="container p-8">
<Content>
<h2 class="text-2xl font-pixel">Minecraft EULA</h2>
<p>
This page is provided as a courtesy. The text below is the contents of
"eula.txt," a file that is generated by the server the first time it
runs. In order to run the server, you must agree to the EULA by
changing "false" to "true."
</p>
<p>
To make this easier, there is a button below. The one and only action
this button takes is to change the value in the text file to true,
this is not an agreement between you and CyborgGrizzly Games.
</p>
<p>
If you are unsure of the contents of the EULA, you are encouraged to
read it by following the link in the text file.
</p>
<pre class="p-2 bg-smoke-500 rounded-lg">{eulaText}</pre>
<div class="mt-8 w-full">
<form action={url.pathname} method="post">
<Button class="ml-auto text-right block" type="submit">
Accept EULA
</Button>
</form>
</div>
</Content>
</div>
);
}

22
routes/setup/fabric.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Content } from "../../components/Content.tsx";
import { FabricVersions } from "../../islands/fabricVersions.tsx";
import { initFabric } from "../../util/initFabric.ts";
export default function FabricSetup() {
try {
const vers = Deno.readDirSync("./fabric/versions");
if (Array.from(vers).length !== 3) throw "";
} catch {
initFabric();
}
return (
<div class="container p-8">
<Content>
<h2 class="font-pixel text-2xl">Fabric Setup</h2>
<p>Select the game version you wish to create a server for. If you know you need a different loader and installer version, you can change it here.</p>
<FabricVersions />
</Content>
</div>
);
}

View File

@ -10,13 +10,13 @@ export default function Setup() {
<div class="m-auto flex gap-4 mt-4"> <div class="m-auto flex gap-4 mt-4">
<a href="/setup/vanilla"> <a href="/setup/vanilla">
<Button>Vanilla</Button> <Button href="/setup/vanilla">Vanilla</Button>
</a> </a>
<a href="/setup/fabric"> <a href="/setup/fabric">
<Button>Fabric</Button> <Button href="/setup/fabric">Fabric</Button>
</a> </a>
<a href="/setup/forge"> <a href="/setup/forge">
<Button>Forge</Button> <Button href="/setup/forge">Forge</Button>
</a> </a>
</div> </div>
</Content> </Content>

27
routes/setup/start.tsx Normal file
View File

@ -0,0 +1,27 @@
import { Handlers } from "$fresh/server.ts";
import { Content } from "../../components/Content.tsx";
import { Terminal } from "../../islands/terminal.tsx";
import { SERVER_STATE } from "../../state/serverState.ts";
export const handler: Handlers = {
async GET(req,ctx) {
if (!SERVER_STATE.eulaAccepted) {
const url = new URL(req.url);
return Response.redirect(`${url.origin}/setup/eula`)
}
const res = await ctx.render();
return res;
}
}
export default function StartFabric() {
SERVER_STATE.startMCServer();
return (
<div class="container p-8">
<Content>
<Terminal channelId={SERVER_STATE.channelId} />
</Content>
</div>
);
}

85
state/serverState.ts Normal file
View File

@ -0,0 +1,85 @@
import { Tail } from "npm:tail";
import { Sockpuppet } from "puppet/client";
import { acceptEULA, checkEULA } from "../util/EULA.ts";
class ServerState {
private status: "running" | "stopped" = "stopped";
private command!: Deno.Command;
private process!: Deno.ChildProcess;
private tail: Tail;
private _eulaAccepted: boolean;
private sockpuppet!: Sockpuppet;
private _channelId = "blanaba";
public get channelId () {
return this._channelId;
}
public get eulaAccepted() {
return this._eulaAccepted;
}
constructor() {
this._eulaAccepted = checkEULA();
this.sockpuppet = new Sockpuppet(
"ws://sockpuppet.cyborggrizzly.com",
() => {
this.sockpuppet.joinChannel(this.channelId, (msg) => {this.sendStdIn(msg)});
},
);
}
public get stdin() {
return this.process.stdin;
}
public get channel() {
return this.sockpuppet.getChannel(this.channelId)
}
public sendStdIn(message: string) {
const msg = new TextEncoder().encode(message);
this.process.stdin.getWriter().write(msg);
}
// "instance" should be moved to a private member once multi-instance support is implemented
public startMCServer(instance = "server") {
this.command = new Deno.Command("java", {
args: [
"-Xmx2G",
"-jar",
"./server.jar",
"nogui",
],
cwd: "./" + instance,
stdin: "piped",
stdout: "piped",
});
this.process = this.command.spawn();
this.startStream();
}
private async startStream() {
const stream = this.process.stdout.getReader();
const decoder = new TextDecoder();
while (true) {
const {done, value} = await stream.read();
if (value) {
this.channel?.send(decoder.decode(value));
}
if (done) break;
}
}
public acceptEULA() {
this._eulaAccepted = true;
acceptEULA();
}
}
export const SERVER_STATE = new ServerState();

View File

@ -30,3 +30,51 @@
* { * {
font-family: 'Titillium Web', sans-serif; font-family: 'Titillium Web', sans-serif;
} }
@layer components {
select, input {
@apply text-black p-2 rounded-xl
}
}
@layer utilities {
.loader-ball {
animation: color-cycle infinite 3s linear;
animation-delay: var(--loader-delay);
@apply m-auto
}
.loader {
animation: spin 5s ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
@keyframes spin {
0% {
transform: rotate(0deg)
}
100% {
transform: rotate(1440deg)
}
}
@keyframes color-cycle {
0% {
@apply bg-fire;
}
25% {
@apply bg-grape;
}
50% {
@apply bg-sky;
}
75% {
@apply bg-wasabi;
}
100% {
@apply bg-fire;
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,21 +0,0 @@
const fabricHost = 'https://meta.fabricmc.net/v2'
const gameVersionSlug = '/versions/game'
const loaderVersionSlug = '/versions/loader'
const installerVersionSlug = '/versions/installer'
const versionSlugs = [gameVersionSlug,loaderVersionSlug,installerVersionSlug];
for (const slug of versionSlugs) {
const uri = fabricHost + slug;
const req = await fetch(uri);
const text = await req.text();
const path = `./fabric/${slug}.json`;
await Deno.mkdir(path.split('/').slice(0,-1).join('/'), {recursive: true});
await Deno.writeTextFile(path, text, {create: true});
}

19
types/fabric.ts Normal file
View File

@ -0,0 +1,19 @@
export type FabricGame = {
version: string;
stable: boolean;
};
export type FabricInstaller = {
url: string;
maven: string;
version: string;
stable: boolean;
};
export type FabricLoader = {
separator: string;
build: number;
maven: string;
version: string;
stable: boolean;
};

11
util/EULA.ts Normal file
View File

@ -0,0 +1,11 @@
const eulaRegex = /(eula=false)/;
export const checkEULA = (instance = 'server') =>
!eulaRegex.test(Deno.readTextFileSync(`./${instance}/eula.txt`));
export const acceptEULA = (instance = 'server') => {
const eula = Deno.readTextFileSync(`./${instance}/eula.txt`);
const mod = eula.replace(eulaRegex, 'eula=true');
console.log(mod)
Deno.writeTextFileSync(`./${instance}/eula.txt`, mod);
}

27
util/initFabric.ts Normal file
View File

@ -0,0 +1,27 @@
export async function initFabric() {
const fabricHost = "https://meta.fabricmc.net/v2";
const gameVersionSlug = "/versions/game";
const loaderVersionSlug = "/versions/loader";
const installerVersionSlug = "/versions/installer";
const versionSlugs = [
gameVersionSlug,
loaderVersionSlug,
installerVersionSlug,
];
for (const slug of versionSlugs) {
const uri = fabricHost + slug;
const req = await fetch(uri);
const text = await req.text();
const path = `./fabric/${slug}.json`;
await Deno.mkdir(path.split("/").slice(0, -1).join("/"), {
recursive: true,
});
await Deno.writeTextFile(path, text, { create: true });
}
}

17
util/startServer.ts Normal file
View File

@ -0,0 +1,17 @@
import { startTail } from "./startTail.ts";
export async function startMCServer(instance = "server") {
const start = new Deno.Command("java", {
args: [
"-Xmx2G",
"-jar",
"./server.jar",
"nogui",
],
cwd: "./" + instance,
});
start.output();
return await startTail(instance);
}

25
util/startTail.ts Normal file
View File

@ -0,0 +1,25 @@
import { Sockpuppet } from "puppet/client";
import { Tail } from "npm:tail";
export function startTail(instance = "server") {
return new Promise<[Tail, string]>((resolve) => {
const puppet = new Sockpuppet("ws://sockpuppet.cyborggrizzly.com", () => {
const channelId = crypto.randomUUID();
puppet.createChannel(channelId);
puppet.joinChannel(channelId, (line) => {
console.log(line);
});
setTimeout(() => {
const path = `./${instance}/logs/latest.log`;
const tail = new Tail(path, { follow: true });
tail.on("line", (line: string) => {
puppet.getChannel(channelId)?.send(line);
console.log(line);
});
resolve([tail, channelId]);
}, 1000);
});
});
}