diff --git a/.github/workflows/shring-outbreak-build.yml b/.github/workflows/shring-outbreak-build.yml new file mode 100644 index 00000000..79e1d89f --- /dev/null +++ b/.github/workflows/shring-outbreak-build.yml @@ -0,0 +1,178 @@ +name: Build Shring Outbreak Pterodactyl ZIP + +on: + push: + branches: + - shring-outbreak-build + paths: + - "shring-outbreak-builder/**" + - ".github/workflows/shring-outbreak-build.yml" + workflow_dispatch: + +permissions: + contents: read + +env: + UPSTREAM_COMMIT: 85df5067b19a876cac4304232cc1e68ff1b07c7f + RELEASE_VERSION: 1.1.0 + PUBLIC_URL: https://shootup.shring.net + +jobs: + build: + name: Build, smoke test, and package + runs-on: ubuntu-24.04 + timeout-minutes: 45 + + steps: + - name: Check out build pipeline + uses: actions/checkout@v4 + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Download pinned Suroi source + shell: bash + run: | + set -euo pipefail + mkdir -p work/suroi + curl --fail --location --retry 4 --retry-delay 3 \ + --output work/suroi.tar.gz \ + "https://github.com/HasangerGames/suroi/archive/${UPSTREAM_COMMIT}.tar.gz" + tar -xzf work/suroi.tar.gz --strip-components=1 -C work/suroi + test -f work/suroi/package.json + + - name: Apply Shring changes + run: node shring-outbreak-builder/apply-patches.mjs work/suroi "$PUBLIC_URL" + + - name: Install build dependencies + working-directory: work/suroi + run: bun install --frozen-lockfile + + - name: Build production browser client + working-directory: work/suroi + env: + NODE_ENV: production + run: bun run build:client + + - name: Validate compiled client + shell: bash + run: | + set -euo pipefail + test -s work/suroi/client/dist/index.html + test -d work/suroi/client/dist/assets + grep -q "Shring Outbreak" work/suroi/client/dist/index.html + ! grep -q "127.0.0.1:8000" work/suroi/client/dist/assets/*.js + + - name: Install production-only dependencies + shell: bash + working-directory: work/suroi + run: | + set -euo pipefail + rm -rf node_modules client/node_modules common/node_modules server/node_modules tests/node_modules + bun install --production --frozen-lockfile + test -d node_modules + du -sh node_modules client/dist + + - name: Assemble flat Pterodactyl package + shell: bash + run: | + set -euo pipefail + PACKAGE="$GITHUB_WORKSPACE/work/package" + mkdir -p "$PACKAGE/bin" "$PACKAGE/suroi" + rsync -a --delete \ + --exclude '.git' \ + --exclude '.github' \ + --exclude 'tests' \ + "$GITHUB_WORKSPACE/work/suroi/" "$PACKAGE/suroi/" + cp "$(command -v bun)" "$PACKAGE/bin/bun" + cp shring-outbreak-builder/runtime/gateway.mjs "$PACKAGE/gateway.mjs" + cp shring-outbreak-builder/runtime/start.mjs "$PACKAGE/start.mjs" + cp shring-outbreak-builder/runtime/start.sh "$PACKAGE/start.sh" + cp shring-outbreak-builder/runtime/.env.example "$PACKAGE/.env.example" + cp shring-outbreak-builder/runtime/README-FIRST.md "$PACKAGE/README-FIRST.md" + cp shring-outbreak-builder/runtime/THIRD_PARTY_NOTICES.md "$PACKAGE/THIRD_PARTY_NOTICES.md" + cat > "$PACKAGE/VERSION.json" < smoke-test.log 2>&1 & + SERVER_PID=$! + cleanup() { + kill -TERM "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + } + trap cleanup EXIT + + healthy=0 + for attempt in $(seq 1 120); do + if curl --fail --silent http://127.0.0.1:31025/health > health.json; then + healthy=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + cat smoke-test.log + exit 1 + fi + sleep 1 + done + + if [ "$healthy" -ne 1 ]; then + cat smoke-test.log + echo "Health endpoint did not become ready" >&2 + exit 1 + fi + + cat health.json + grep -q '"ok":true' health.json + grep -q '"guestPlay":true' health.json + curl --fail --silent http://127.0.0.1:31025/ | grep -q "Shring Outbreak" + curl --fail --silent http://127.0.0.1:31025/api/serverInfo > server-info.json + curl --fail --silent http://127.0.0.1:31025/api/getGame > game.json + cat server-info.json + cat game.json + + - name: Create upload ZIP and checksum + shell: bash + run: | + set -euo pipefail + ZIP="Shring-Outbreak-Suroi-v${RELEASE_VERSION}-PREBUILT-FLAT.zip" + cd work/package + zip -r -6 "$GITHUB_WORKSPACE/$ZIP" . + cd "$GITHUB_WORKSPACE" + sha256sum "$ZIP" > "$ZIP.sha256" + unzip -t "$ZIP" + SIZE=$(stat -c%s "$ZIP") + echo "ZIP size: $SIZE bytes" + if [ "$SIZE" -gt 524288000 ]; then + echo "ZIP exceeds the 500 MB Pterodactyl upload target" >&2 + exit 1 + fi + + - name: Upload Pterodactyl artifact + uses: actions/upload-artifact@v4 + with: + name: shring-outbreak-pterodactyl-v1.1.0 + path: | + Shring-Outbreak-Suroi-v1.1.0-PREBUILT-FLAT.zip + Shring-Outbreak-Suroi-v1.1.0-PREBUILT-FLAT.zip.sha256 + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.github/workflows/shring-outbreak-pr-build.yml b/.github/workflows/shring-outbreak-pr-build.yml new file mode 100644 index 00000000..32344b5d --- /dev/null +++ b/.github/workflows/shring-outbreak-pr-build.yml @@ -0,0 +1,51 @@ +name: Shring Outbreak PR artifact + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + paths: + - "shring-outbreak-builder/**" + - ".github/workflows/shring-outbreak-pr-build.yml" + workflow_dispatch: + +permissions: + contents: read + +env: + UPSTREAM_COMMIT: 85df5067b19a876cac4304232cc1e68ff1b07c7f + RELEASE_VERSION: 1.1.0 + PUBLIC_URL: https://shootup.shring.net + +jobs: + build: + name: Build verified Pterodactyl ZIP + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Check out build branch + uses: actions/checkout@v4 + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Build and smoke test package + shell: bash + run: bash shring-outbreak-builder/build.sh + + - name: Upload Pterodactyl artifact + uses: actions/upload-artifact@v4 + with: + name: shring-outbreak-pterodactyl-v1.1.0 + path: | + Shring-Outbreak-Suroi-v1.1.0-PREBUILT-FLAT.zip + Shring-Outbreak-Suroi-v1.1.0-PREBUILT-FLAT.zip.sha256 + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/shring-outbreak-builder/README.md b/shring-outbreak-builder/README.md new file mode 100644 index 00000000..2c095dbe --- /dev/null +++ b/shring-outbreak-builder/README.md @@ -0,0 +1,18 @@ +# Shring Outbreak build pipeline + +This directory builds a guest-playable Shring-branded deployment from the GPL-3.0 Suroi source at a pinned upstream commit. + +It exists only on the `shring-outbreak-build` branch and does not modify the Void Arena `main` branch. + +The GitHub Actions workflow performs all memory-intensive client and sprite compilation on GitHub-hosted runners, smoke-tests the completed runtime, and uploads a flat Pterodactyl ZIP. The Pterodactyl server does not compile the client during startup. + +Deployment defaults: + +- Public URL: `https://shootup.shring.net` +- Public allocation: `31025` +- Internal Suroi main port: `8000` +- Internal game worker port: `8001` +- Guest play: enabled +- Shring Auth: not required + +The resulting package contains the modified source, compiled client, production dependencies, a portable Bun runtime, GPL license, modification notice, single-port gateway, and health endpoint. diff --git a/shring-outbreak-builder/apply-patches.mjs b/shring-outbreak-builder/apply-patches.mjs new file mode 100644 index 00000000..166f639e --- /dev/null +++ b/shring-outbreak-builder/apply-patches.mjs @@ -0,0 +1,187 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +const [sourceArg, publicUrlArg = "https://shootup.shring.net"] = process.argv.slice(2); +if (!sourceArg) throw new Error("Usage: node apply-patches.mjs [public-url]"); + +const sourceDir = path.resolve(sourceArg); +const publicUrl = publicUrlArg.replace(/\/$/, ""); +const upstreamCommit = process.env.UPSTREAM_COMMIT || "85df5067b19a876cac4304232cc1e68ff1b07c7f"; + +async function read(relative) { + return fs.readFile(path.join(sourceDir, relative), "utf8"); +} + +async function write(relative, content) { + const file = path.join(sourceDir, relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content, "utf8"); +} + +async function replace(relative, transform) { + const original = await read(relative); + const updated = transform(original); + await write(relative, updated); +} + +function fullLogo() { + return ` + + + + + + + + SHRING + OUTBREAK +`; +} + +function favicon() { + return ` + + + + + +`; +} + +const wsPublicUrl = publicUrl.replace(/^http/, "ws"); +await write("client/src/scripts/config.ts", `import { type TeamMode } from "@common/constants"; +import type { ModeName } from "@common/definitions/modes"; + +export const Config = { + regions: { + shring: { + name: "Shring Network", + mainAddress: "${publicUrl}", + gameAddress: "${wsPublicUrl}/game/", + offset: 1 + } + }, + defaultRegion: "shring" +} satisfies ConfigType as ConfigType; + +export interface ConfigType { + readonly regions: Record + readonly defaultRegion: string +} + +export interface Region { + readonly name: string + readonly flag?: string + readonly mainAddress: string + readonly gameAddress: string + readonly offset: number +} + +export interface ServerInfo { + readonly protocolVersion: number + readonly playerCount: number + readonly teamMode: TeamMode + readonly nextTeamMode?: TeamMode + readonly teamModeSwitchTime?: number + readonly mode: ModeName + readonly nextMode?: ModeName + readonly modeSwitchTime?: number +} +`); + +await write("server/config.json", `${JSON.stringify({ + $schema: "config.schema.json", + hostname: "127.0.0.1", + port: 8000, + map: "normal", + teamMode: "solo", + spawn: { mode: "default" }, + minTeamsToStart: 1, + maxPlayersPerGame: 40, + maxGames: 1, + ipHeader: "X-Real-IP", + maxSimultaneousConnections: 5, + maxJoinAttempts: { count: 12, duration: 60000 }, + maxCustomTeams: 5, + roles: {} +}, null, 2)}\n`); + +await replace("package.json", text => { + const pkg = JSON.parse(text); + pkg.name = "shring-outbreak"; + pkg.description = "A Shring Network deployment of the open-source Suroi browser shooter."; + return `${JSON.stringify(pkg, null, 2)}\n`; +}); + +await replace("client/index.html", text => { + let output = text + .replace(/]*>\s*/g, "") + .replaceAll("Suroi - 2D battle royale game", "Shring Outbreak - Browser Battle Royale") + .replaceAll("Miss surviv.io and Surviv Reloaded? Suroi is an open-source 2D battle royale game inspired by surviv.io. Work in progress.", "A fast, guest-playable 2D browser battle royale for the Shring Network.") + .replaceAll("suroi.io", "shootup.shring.net") + .replaceAll("alt=\"Suroi logo\"", "alt=\"Shring Outbreak logo\"") + .replace(/\s*/g, ""); + if (!output.includes("id=\"shring-source-notice\"")) { + output = output.replace("", `\n `); + } + return output; +}); + +await fs.appendFile(path.join(sourceDir, "client/src/scss/pages/client/splash.scss"), ` +/* Shring Outbreak deployment branding */ +#splash-news { display: none !important; } +#splash-center { margin-inline: auto !important; } +#shring-source-notice { position: fixed; right: 14px; bottom: 10px; z-index: 20; font: 600 12px/1.2 Arial,sans-serif; opacity: .78; } +#shring-source-notice a { color: #e8f5e8; text-decoration: none; } +#shring-source-notice a:hover { text-decoration: underline; opacity: 1; } +`, "utf8"); + +await write("client/public/img/logos/suroi_beta.svg", fullLogo()); +await write("client/public/img/logos/suroi_favicon.svg", favicon()); + +await replace("client/public/manifest.json", text => { + const manifest = JSON.parse(text); + manifest.name = "Shring Outbreak - 2D Battle Royale"; + manifest.short_name = "Shring Outbreak"; + manifest.description = "Guest-playable 2D browser battle royale for the Shring Network."; + manifest.icons = [{ src: "./img/logos/suroi_favicon.svg", type: "image/svg+xml", sizes: "any" }]; + manifest.shortcuts = []; + return `${JSON.stringify(manifest, null, 2)}\n`; +}); + +try { + await replace("client/src/translations/en.hjson", text => text.replaceAll("Suroi", "Shring Outbreak").replaceAll("suroi.io", "shootup.shring.net")); +} catch (error) { + console.warn("English translation branding patch skipped:", error.message); +} + +await replace("server/src/server.ts", text => text.replace("Suroi Server v${version}", "Shring Outbreak Server v${version}")); + +await write("client/public/source.html", ` +Shring Outbreak source and credits + +

Shring Outbreak source and credits

This deployment is a modified build of Suroi, an open-source 2D battle royale game by the Suroi contributors.

+

Upstream project: HasangerGames/suroi

Pinned upstream commit: ${upstreamCommit}

+

The upstream project and this modified deployment are provided under the GNU General Public License version 3. The complete modified source is included in the deployment under /home/container/suroi.

+

Shring modifications: branding, one Shring region, guest play, production configuration, precompiled browser client, health endpoint, and a single-port Pterodactyl gateway.

`); + +await write("SHRING-MODIFICATIONS.md", `# Shring Outbreak modification notice + +Modified from Suroi for the Shring Network on 2026-08-03. + +- Upstream: https://github.com/HasangerGames/suroi +- Pinned commit: ${upstreamCommit} +- License: GNU GPL version 3 +- Public URL: ${publicUrl} + +Changes: + +- Shring Outbreak branding and metadata +- guest-playable deployment with no required account +- one Shring server region +- production client compiled before deployment +- single public-port gateway for Pterodactyl +- health endpoint and deployment scripts +`); + +console.log(`Applied Shring Outbreak patches to ${sourceDir}`); diff --git a/shring-outbreak-builder/build.sh b/shring-outbreak-builder/build.sh new file mode 100644 index 00000000..9426f6de --- /dev/null +++ b/shring-outbreak-builder/build.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${UPSTREAM_COMMIT:=85df5067b19a876cac4304232cc1e68ff1b07c7f}" +: "${RELEASE_VERSION:=1.1.0}" +: "${PUBLIC_URL:=https://shootup.shring.net}" + +ROOT="${GITHUB_WORKSPACE:-$(pwd)}" +WORK="$ROOT/work" +SOURCE="$WORK/suroi" +PACKAGE="$WORK/package" +ZIP="Shring-Outbreak-Suroi-v${RELEASE_VERSION}-PREBUILT-FLAT.zip" + +rm -rf "$WORK" "$ROOT/$ZIP" "$ROOT/$ZIP.sha256" +mkdir -p "$SOURCE" + +echo "[build] Downloading pinned Suroi source $UPSTREAM_COMMIT" +curl --fail --location --retry 4 --retry-delay 3 \ + --output "$WORK/suroi.tar.gz" \ + "https://github.com/HasangerGames/suroi/archive/${UPSTREAM_COMMIT}.tar.gz" +tar -xzf "$WORK/suroi.tar.gz" --strip-components=1 -C "$SOURCE" +test -f "$SOURCE/package.json" + +echo "[build] Applying Shring changes" +node "$ROOT/shring-outbreak-builder/apply-patches.mjs" "$SOURCE" "$PUBLIC_URL" + +echo "[build] Installing build dependencies" +cd "$SOURCE" +bun install --frozen-lockfile + +echo "[build] Compiling production browser client" +NODE_ENV=production bun run build:client + +test -s client/dist/index.html +test -d client/dist/scripts +test -d client/dist/img +grep -q "Shring Outbreak" client/dist/index.html +if grep -R -q "127.0.0.1:8000" client/dist/scripts; then + echo "Compiled client still contains the development server address" >&2 + exit 1 +fi + +echo "[build] Replacing build dependencies with production dependencies" +rm -rf node_modules client/node_modules common/node_modules server/node_modules tests/node_modules +bun install --production --frozen-lockfile +test -d node_modules +du -sh node_modules client/dist + +echo "[build] Assembling flat Pterodactyl package" +mkdir -p "$PACKAGE/bin" "$PACKAGE/suroi" +rsync -a --delete \ + --exclude '.git' \ + --exclude '.github' \ + --exclude 'tests' \ + "$SOURCE/" "$PACKAGE/suroi/" +cp "$(command -v bun)" "$PACKAGE/bin/bun" +cp "$ROOT/shring-outbreak-builder/runtime/gateway.mjs" "$PACKAGE/gateway.mjs" +cp "$ROOT/shring-outbreak-builder/runtime/start.mjs" "$PACKAGE/start.mjs" +cp "$ROOT/shring-outbreak-builder/runtime/start.sh" "$PACKAGE/start.sh" +cp "$ROOT/shring-outbreak-builder/runtime/.env.example" "$PACKAGE/.env.example" +cp "$ROOT/shring-outbreak-builder/runtime/README-FIRST.md" "$PACKAGE/README-FIRST.md" +cp "$ROOT/shring-outbreak-builder/runtime/THIRD_PARTY_NOTICES.md" "$PACKAGE/THIRD_PARTY_NOTICES.md" +cat > "$PACKAGE/VERSION.json" </dev/null 2>&1; then + echo "Smoke-test PATH unexpectedly contains a global Bun executable" >&2 + exit 1 +fi +PATH="$SMOKE_PATH" ./start.sh > smoke-test.log 2>&1 & +SERVER_PID=$! +cleanup() { + kill -TERM "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +healthy=0 +for attempt in $(seq 1 120); do + if curl --fail --silent http://127.0.0.1:31025/health > health.json; then + healthy=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + cat smoke-test.log + exit 1 + fi + sleep 1 +done + +if [ "$healthy" -ne 1 ]; then + cat smoke-test.log + echo "Health endpoint did not become ready" >&2 + exit 1 +fi + +cat health.json +grep -q '"ok":true' health.json +grep -q '"guestPlay":true' health.json +curl --fail --silent http://127.0.0.1:31025/ | grep -q "Shring Outbreak" +curl --fail --silent http://127.0.0.1:31025/api/serverInfo > server-info.json +curl --fail --silent http://127.0.0.1:31025/api/getGame > game.json +cat server-info.json +cat game.json +cleanup +trap - EXIT + +rm -f smoke-test.log health.json server-info.json game.json .env + +echo "[build] Creating upload ZIP with workspace symlinks preserved" +cd "$PACKAGE" +zip -r -y -6 "$ROOT/$ZIP" . +cd "$ROOT" +sha256sum "$ZIP" > "$ZIP.sha256" +unzip -t "$ZIP" +SIZE=$(stat -c%s "$ZIP") +echo "ZIP size: $SIZE bytes" +if [ "$SIZE" -gt 524288000 ]; then + echo "ZIP exceeds the 500 MB Pterodactyl upload target" >&2 + exit 1 +fi + +echo "[build] Completed $ZIP" diff --git a/shring-outbreak-builder/runtime/.env.example b/shring-outbreak-builder/runtime/.env.example new file mode 100644 index 00000000..a3350cb4 --- /dev/null +++ b/shring-outbreak-builder/runtime/.env.example @@ -0,0 +1,9 @@ +SERVER_HOST=0.0.0.0 +SERVER_PORT=31025 +PUBLIC_URL=https://shootup.shring.net +INTERNAL_BASE_PORT=8000 +MAX_GAMES=1 +MAX_PLAYERS=40 +TEAM_MODE=solo +MAP_NAME=normal +ALLOW_GUESTS=1 diff --git a/shring-outbreak-builder/runtime/README-FIRST.md b/shring-outbreak-builder/runtime/README-FIRST.md new file mode 100644 index 00000000..5cdf18c2 --- /dev/null +++ b/shring-outbreak-builder/runtime/README-FIRST.md @@ -0,0 +1,52 @@ +# Shring Outbreak — precompiled Pterodactyl package + +This package is built on GitHub Actions from a pinned Suroi commit. The memory-intensive Vite and sprite compilation has already been completed before deployment. + +## Target + +- Public URL: `https://shootup.shring.net` +- Pterodactyl allocation: `192.168.1.65:31025` +- Egg: Node.js 22 +- Account required: no +- Existing nginx, DNS, TLS certificate, and Certbot configuration: unchanged + +## Install + +1. Stop the Pterodactyl server. +2. Remove the previous bootstrap package and its partially built `suroi` folder. +3. Extract this ZIP directly into `/home/container`. +4. Set the startup command to: + +```bash +bash ./start.sh +``` + +5. Start the server. + +The server should start the already-compiled game directly. It must not run Vite, spritesheetc, `bun install`, or any build step. + +## Verify + +```bash +curl -fsS http://192.168.1.65:31025/health +curl -fsS https://shootup.shring.net/health +``` + +Healthy output includes: + +```json +{"ok":true,"service":"shring-outbreak","guestPlay":true} +``` + +## Package layout + +- `bin/bun` — portable Bun runtime built into the package +- `suroi/` — complete modified GPL source, compiled client, and production dependencies +- `start.mjs` — process supervisor +- `gateway.mjs` — static server and single-port HTTP/WebSocket gateway +- `VERSION.json` — build provenance +- `THIRD_PARTY_NOTICES.md` — upstream and licensing notice + +## Current gameplay scope + +This is the stable Suroi multiplayer shooter base under Shring branding. It is guest-playable and does not require Shring Auth. Zombie waves, vehicles, construction, and persistent progression are not claimed as implemented in this release. diff --git a/shring-outbreak-builder/runtime/THIRD_PARTY_NOTICES.md b/shring-outbreak-builder/runtime/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..4917d025 --- /dev/null +++ b/shring-outbreak-builder/runtime/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# Third-party notices + +Shring Outbreak is a modified deployment of the open-source Suroi project. + +- Upstream: `HasangerGames/suroi` +- Pinned source commit: `85df5067b19a876cac4304232cc1e68ff1b07c7f` +- Upstream license: GNU General Public License version 3 + +The complete modified source and the upstream `LICENSE` file are included in the deployment under `/home/container/suroi`. + +Shring-specific changes are documented in `/home/container/suroi/SHRING-MODIFICATIONS.md`. diff --git a/shring-outbreak-builder/runtime/gateway.mjs b/shring-outbreak-builder/runtime/gateway.mjs new file mode 100644 index 00000000..e0b98988 --- /dev/null +++ b/shring-outbreak-builder/runtime/gateway.mjs @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; + +const MIME = new Map([ + [".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".mjs", "text/javascript; charset=utf-8"], + [".css", "text/css; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".svg", "image/svg+xml"], + [".png", "image/png"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".webp", "image/webp"], [".gif", "image/gif"], + [".ico", "image/x-icon"], [".woff", "font/woff"], [".woff2", "font/woff2"], [".mp3", "audio/mpeg"], [".ogg", "audio/ogg"], [".wasm", "application/wasm"] +]); + +function proxyHttp(req, res, port, rewrite = value => value) { + const headers = { ...req.headers, host: `127.0.0.1:${port}` }; + const upstream = http.request({ hostname: "127.0.0.1", port, path: rewrite(req.url || "/"), method: req.method, headers }, upstreamRes => { + res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers); + upstreamRes.pipe(res); + }); + upstream.on("error", error => { + if (!res.headersSent) res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: "upstream_unavailable", message: error.message })); + }); + req.pipe(upstream); +} + +function proxyUpgrade(req, socket, head, port, rewrite = value => value) { + const upstream = net.connect(port, "127.0.0.1"); + upstream.setNoDelay(true); + socket.setNoDelay(true); + upstream.once("connect", () => { + const url = rewrite(req.url || "/"); + const headers = { ...req.headers, host: `127.0.0.1:${port}`, connection: "Upgrade", upgrade: req.headers.upgrade || "websocket" }; + let request = `${req.method || "GET"} ${url} HTTP/${req.httpVersion}\r\n`; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) for (const item of value) request += `${key}: ${item}\r\n`; + else request += `${key}: ${value}\r\n`; + } + request += "\r\n"; + upstream.write(request); + if (head?.length) upstream.write(head); + socket.pipe(upstream).pipe(socket); + }); + const fail = error => { + try { socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); } catch {} + socket.destroy(error); + upstream.destroy(); + }; + upstream.once("error", fail); + socket.once("error", () => upstream.destroy()); +} + +export function createGateway({ host, port, internalBasePort, maxGames, staticRoot, version, upstreamCommit }) { + const server = http.createServer(async(req, res) => { + try { + const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + const pathname = decodeURIComponent(requestUrl.pathname); + res.setHeader("x-content-type-options", "nosniff"); + res.setHeader("referrer-policy", "same-origin"); + res.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()"); + + if (pathname === "/health") { + const check = await new Promise(resolve => { + const probe = http.get({ hostname: "127.0.0.1", port: internalBasePort, path: "/api/serverInfo", timeout: 2500 }, response => { + response.resume(); resolve(response.statusCode === 200); + }); + probe.on("timeout", () => { probe.destroy(); resolve(false); }); + probe.on("error", () => resolve(false)); + }); + res.writeHead(check ? 200 : 503, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + res.end(JSON.stringify({ ok: check, service: "shring-outbreak", version, upstream: "suroi", upstreamCommit, guestPlay: true })); + return; + } + + if (pathname.startsWith("/api/") || pathname === "/team") { + proxyHttp(req, res, internalBasePort); + return; + } + + const gameMatch = pathname.match(/^\/game\/(\d+)(\/.*)?$/); + if (gameMatch) { + const gamePortNumber = Number(gameMatch[1]); + if (!Number.isInteger(gamePortNumber) || gamePortNumber < 1 || gamePortNumber > maxGames) { + res.writeHead(404); res.end("Unknown game server"); return; + } + proxyHttp(req, res, internalBasePort + gamePortNumber, url => url.replace(/^\/game\/\d+/, "") || "/play"); + return; + } + + let relative = pathname.replace(/^\/+/, ""); + if (!relative || relative.endsWith("/")) relative += "index.html"; + let file = path.resolve(staticRoot, relative); + const rootResolved = path.resolve(staticRoot) + path.sep; + if (!file.startsWith(rootResolved)) { res.writeHead(403); res.end("Forbidden"); return; } + let stat; + try { stat = await fsp.stat(file); } catch { stat = undefined; } + if (!stat?.isFile()) file = path.join(staticRoot, "index.html"); + const ext = path.extname(file).toLowerCase(); + const headers = { + "content-type": MIME.get(ext) || "application/octet-stream", + "cache-control": ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable" + }; + const fileStat = await fsp.stat(file); + headers["content-length"] = fileStat.size; + res.writeHead(200, headers); + await pipeline(fs.createReadStream(file), res); + } catch (error) { + console.error("[gateway] HTTP error", error); + if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); + res.end("Internal server error"); + } + }); + + server.on("upgrade", (req, socket, head) => { + const pathname = new URL(req.url || "/", "http://localhost").pathname; + if (pathname === "/team") { + proxyUpgrade(req, socket, head, internalBasePort); + return; + } + const match = pathname.match(/^\/game\/(\d+)(\/.*)?$/); + if (!match) { socket.destroy(); return; } + const gameNumber = Number(match[1]); + if (!Number.isInteger(gameNumber) || gameNumber < 1 || gameNumber > maxGames) { socket.destroy(); return; } + proxyUpgrade(req, socket, head, internalBasePort + gameNumber, url => url.replace(/^\/game\/\d+/, "") || "/play"); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + console.log(`[gateway] Listening on http://${host}:${port}`); + resolve(server); + }); + }); +} diff --git a/shring-outbreak-builder/runtime/start.mjs b/shring-outbreak-builder/runtime/start.mjs new file mode 100644 index 00000000..b0797b52 --- /dev/null +++ b/shring-outbreak-builder/runtime/start.mjs @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import process from "node:process"; +import { spawn } from "node:child_process"; +import { createGateway } from "./gateway.mjs"; + +const ROOT = path.resolve(import.meta.dirname); +const SOURCE_DIR = path.join(ROOT, "suroi"); +const BIN_DIR = path.join(ROOT, "bin"); +const BUN = path.join(BIN_DIR, "bun"); +const VERSION_FILE = path.join(ROOT, "VERSION.json"); + +function parseEnv(file) { + if (!fs.existsSync(file)) return {}; + const out = {}; + for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const i = line.indexOf("="); + if (i < 1) continue; + out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^['"]|['"]$/g, ""); + } + return out; +} + +const env = { ...parseEnv(path.join(ROOT, ".env")), ...process.env }; +const host = env.SERVER_HOST || "0.0.0.0"; +const port = Number(env.SERVER_PORT || 31025); +const internalBasePort = Number(env.INTERNAL_BASE_PORT || 8000); +const maxGames = Math.max(1, Math.min(5, Number(env.MAX_GAMES || 1))); +const metadata = JSON.parse(fs.readFileSync(VERSION_FILE, "utf8")); + +for (const required of [BUN, path.join(SOURCE_DIR, "package.json"), path.join(SOURCE_DIR, "client", "dist", "index.html"), path.join(SOURCE_DIR, "node_modules")]) { + if (!fs.existsSync(required)) throw new Error(`Production package is incomplete: missing ${required}`); +} + +let child; +let gateway; +let stopping = false; + +function shutdown(signal = "SIGTERM") { + if (stopping) return; + stopping = true; + console.log(`[start] Received ${signal}; shutting down`); + try { gateway?.close(); } catch {} + try { child?.kill("SIGTERM"); } catch {} + setTimeout(() => process.exit(0), 3000).unref(); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); + +async function waitForMainServer(timeoutMs = 120000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (child?.exitCode !== null && child?.exitCode !== undefined) throw new Error(`Game server exited before becoming ready (code ${child.exitCode})`); + const ready = await new Promise(resolve => { + const req = http.get({ hostname: "127.0.0.1", port: internalBasePort, path: "/api/serverInfo", timeout: 2000 }, res => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on("timeout", () => { req.destroy(); resolve(false); }); + req.on("error", () => resolve(false)); + }); + if (ready) return; + await new Promise(resolve => setTimeout(resolve, 1000)); + } + throw new Error(`Internal game API did not become ready on 127.0.0.1:${internalBasePort}`); +} + +try { + const runtimePath = [BIN_DIR, process.env.PATH || ""].filter(Boolean).join(path.delimiter); + child = spawn(BUN, ["run", "start"], { + cwd: SOURCE_DIR, + stdio: "inherit", + env: { ...process.env, PATH: runtimePath, NODE_ENV: "production" } + }); + child.once("error", error => { console.error("[start] Game process failed", error); process.exit(1); }); + child.once("exit", code => { if (!stopping) { console.error(`[start] Game process exited with code ${code}`); process.exit(code || 1); } }); + console.log("[start] Waiting for the internal game API..."); + await waitForMainServer(); + gateway = await createGateway({ host, port, internalBasePort, maxGames, staticRoot: path.join(SOURCE_DIR, "client", "dist"), version: metadata.version, upstreamCommit: metadata.upstreamCommit }); + console.log(`[start] Shring Outbreak ${metadata.version} is ready`); + console.log("[start] Guest play is enabled; no account is required"); + console.log(`[start] Public URL: ${env.PUBLIC_URL || "https://shootup.shring.net"}`); +} catch (error) { + console.error("[start] Fatal startup error:", error?.stack || error); + try { child?.kill("SIGTERM"); } catch {} + process.exit(1); +} diff --git a/shring-outbreak-builder/runtime/start.sh b/shring-outbreak-builder/runtime/start.sh new file mode 100644 index 00000000..0c05846b --- /dev/null +++ b/shring-outbreak-builder/runtime/start.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /home/container 2>/dev/null || cd "$(dirname "$0")" + +if [ ! -f .env ] && [ -f .env.example ]; then + cp .env.example .env +fi + +chmod +x ./bin/bun + +if [ ! -x ./bin/bun ]; then + echo "[start] ERROR: packaged Bun runtime is missing" >&2 + exit 1 +fi + +if [ ! -f ./suroi/client/dist/index.html ]; then + echo "[start] ERROR: precompiled browser client is missing" >&2 + exit 1 +fi + +if [ ! -d ./suroi/node_modules ]; then + echo "[start] ERROR: production dependencies are missing" >&2 + exit 1 +fi + +exec node ./start.mjs