From 09d3d949d41d801b356bc8e0b52e43c80efd8543 Mon Sep 17 00:00:00 2001 From: "Timmy (NEXUSBURN)" Date: Mon, 13 Apr 2026 18:24:59 -0400 Subject: [PATCH] feat: unified HTTP+WS server for proper URL deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1339. The Nexus Three.js app required HTTP serving for module imports to work (file:// and raw Forge URLs break). This change: - server.py: dual-purpose async server — HTTP on :8080 (static files) and WebSocket on :8765 (broadcast gateway), single process - Dockerfile: exposes both 8080 (HTTP) and 8765 (WS), includes all frontend assets (boot.js, bootstrap.mjs, gofai_worker.js, etc.) - docker-compose.yml: maps HTTP :8080/:8081 + WS :8765/:8766 - deploy.sh: updated for new port layout - run.sh: standalone no-Docker launcher - app.js: WS URL derives from HTTP port (8080->8765, 8081->8766) - deploy.yml workflow: docker compose on remote host To deploy: `./deploy.sh` (Docker) or `./run.sh` (bare metal). Open http://HOST:8080 in a browser — Three.js modules load correctly. --- .gitea/workflows/deploy.yml | 6 +- Dockerfile | 7 +- app.js | 6 +- deploy.sh | 12 +- docker-compose.yml | 4 +- run.sh | 16 +++ server.py | 233 +++++++++++++++++++++++++----------- 7 files changed, 209 insertions(+), 75 deletions(-) create mode 100755 run.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 87796dcb..f892e550 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -27,8 +27,10 @@ jobs: username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | - cd ~/the-nexus || git clone http://143.198.27.163:3000/Timmy_Foundation/the-nexus.git ~/the-nexus + cd ~/the-nexus || git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus.git ~/the-nexus cd ~/the-nexus git fetch origin main git reset --hard origin/main - ./deploy.sh main + docker compose build nexus-main + docker compose up -d --force-recreate nexus-main + echo "Nexus deployed — HTTP :8080, WS :8765" diff --git a/Dockerfile b/Dockerfile index 5a010c04..e423e4b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,11 +11,16 @@ COPY nexus/ nexus/ COPY server.py ./ # Frontend assets referenced by index.html -COPY index.html help.html style.css app.js service-worker.js manifest.json ./ +COPY index.html help.html style.css app.js boot.js bootstrap.mjs gofai_worker.js mempalace.js service-worker.js manifest.json ./ # Config/data COPY portals.json vision.json robots.txt ./ +# Icons +COPY icons/ icons/ + +# Expose HTTP (static) and WebSocket +EXPOSE 8080 EXPOSE 8765 CMD ["python3", "server.py"] diff --git a/app.js b/app.js index f9515169..6e463b49 100644 --- a/app.js +++ b/app.js @@ -2188,7 +2188,11 @@ function connectHermes() { } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/api/world/ws`; + // WS gateway runs on HTTP port - 115 (8080->8765, 8081->8766) + const wsHost = window.location.hostname; + const httpPort = parseInt(window.location.port) || 8080; + const wsPort = httpPort === 8081 ? 8766 : 8765; + const wsUrl = `${protocol}//${wsHost}:${wsPort}/api/world/ws`; console.log(`Connecting to Hermes at ${wsUrl}...`); hermesWs = new WebSocket(wsUrl); diff --git a/deploy.sh b/deploy.sh index 76f1fd3b..0a2b62ba 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # deploy.sh — spin up (or update) the Nexus staging environment -# Usage: ./deploy.sh — rebuild and restart nexus-main (port 4200) -# ./deploy.sh staging — rebuild and restart nexus-staging (port 4201) +# Usage: ./deploy.sh — rebuild and restart nexus-main (HTTP :8080, WS :8765) +# ./deploy.sh staging — rebuild and restart nexus-staging (HTTP :8081, WS :8766) set -euo pipefail SERVICE="${1:-nexus-main}" @@ -14,4 +14,12 @@ esac echo "==> Deploying $SERVICE …" docker compose build "$SERVICE" docker compose up -d --force-recreate "$SERVICE" + +if [ "$SERVICE" = "nexus-main" ]; then + echo "==> HTTP: http://localhost:8080" + echo "==> WS: ws://localhost:8765" +else + echo "==> HTTP: http://localhost:8081" + echo "==> WS: ws://localhost:8766" +fi echo "==> Done. Container: $SERVICE" diff --git a/docker-compose.yml b/docker-compose.yml index ab351d5c..1c83a8e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,12 @@ services: container_name: nexus-main restart: unless-stopped ports: + - "8080:8080" - "8765:8765" nexus-staging: build: . container_name: nexus-staging restart: unless-stopped ports: - - "8766:8765" \ No newline at end of file + - "8081:8080" + - "8766:8765" diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..9afced68 --- /dev/null +++ b/run.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# run.sh — run Nexus locally without Docker +# Usage: ./run.sh — HTTP :8080, WS :8765 +# NEXUS_HTTP_PORT=9090 ./run.sh — custom HTTP port +set -euo pipefail + +cd "$(dirname "$0")" + +# Install deps if missing +if ! python3 -c "import websockets" 2>/dev/null; then + echo "==> Installing dependencies..." + pip3 install -r requirements.txt +fi + +echo "==> Starting Nexus server..." +exec python3 server.py diff --git a/server.py b/server.py index 02350978..adb1e586 100644 --- a/server.py +++ b/server.py @@ -1,92 +1,190 @@ #!/usr/bin/env python3 """ -The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness. -This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py), -the body (Evennia/Morrowind), and the visualization surface. +The Nexus — Unified HTTP + WebSocket server. + +Serves static frontend files (Three.js app) over HTTP on port 8080 +and runs the WebSocket gateway on port 8765. +Single-process, single-command deployment — no nginx required. """ import asyncio import json import logging +import mimetypes +import os import signal import sys +from http import HTTPStatus +from pathlib import Path from typing import Set -# Branch protected file - see POLICY.md import websockets +import websockets.asyncio.server +# --------------------------------------------------------------------------- # Configuration -PORT = 8765 -HOST = "0.0.0.0" # Allow external connections if needed +# --------------------------------------------------------------------------- +HTTP_PORT = int(os.environ.get("NEXUS_HTTP_PORT", "8080")) +WS_PORT = int(os.environ.get("NEXUS_WS_PORT", "8765")) +HOST = os.environ.get("NEXUS_HOST", "0.0.0.0") +ROOT = Path(__file__).resolve().parent -# Logging setup +# Static file extensions we're willing to serve +SAFE_SUFFIXES = { + ".html", ".htm", ".css", ".js", ".mjs", ".json", + ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", + ".woff", ".woff2", ".ttf", ".eot", + ".txt", ".xml", ".webmanifest", +} + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- logging.basicConfig( level=logging.INFO, - format='%(asctime)s [%(levelname)s] %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) -logger = logging.getLogger("nexus-gateway") +log = logging.getLogger("nexus") -# State -clients: Set[websockets.WebSocketServerProtocol] = set() +# --------------------------------------------------------------------------- +# HTTP — static file server +# --------------------------------------------------------------------------- +async def http_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + """Minimal async HTTP/1.1 server for static files.""" + try: + # Read request line + headers (max 8 KB) + data = b"" + while b"\r\n\r\n" not in data and len(data) < 8192: + chunk = await asyncio.wait_for(reader.read(4096), timeout=10) + if not chunk: + return + data += chunk + header_text = data.split(b"\r\n\r\n", 1)[0].decode("utf-8", errors="replace") + lines = header_text.split("\r\n") + request_line = lines[0] + parts = request_line.split(" ", 2) + if len(parts) < 2: + writer.close() + return + method, raw_path = parts[0], parts[1] -async def broadcast_handler(websocket: websockets.WebSocketServerProtocol): - """Handles individual client connections and message broadcasting.""" - clients.add(websocket) - addr = websocket.remote_address - logger.info(f"Client connected from {addr}. Total clients: {len(clients)}") - + if method not in ("GET", "HEAD"): + _write_response(writer, HTTPStatus.METHOD_NOT_ALLOWED, b"Method Not Allowed") + return + + # Normalise path — prevent directory traversal + safe_path = raw_path.split("?", 1)[0].split("#", 1)[0] + safe_path = os.path.normpath(safe_path).lstrip("/") + if not safe_path: + safe_path = "index.html" + + file_path = ROOT / safe_path + # Reject traversal + if not str(file_path.resolve()).startswith(str(ROOT)): + _write_response(writer, HTTPStatus.FORBIDDEN, b"Forbidden") + return + + if not file_path.exists() or file_path.is_dir(): + # Try index.html for directories + if file_path.is_dir() and (file_path / "index.html").exists(): + file_path = file_path / "index.html" + else: + _write_response(writer, HTTPStatus.NOT_FOUND, b"Not Found") + return + + suffix = file_path.suffix.lower() + if suffix not in SAFE_SUFFIXES: + _write_response(writer, HTTPStatus.FORBIDDEN, b"Forbidden") + return + + content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + body = file_path.read_bytes() + headers = { + "Content-Type": content_type, + "Content-Length": str(len(body)), + "Cache-Control": "no-cache", + "Access-Control-Allow-Origin": "*", + } + _write_response(writer, HTTPStatus.OK, body, headers, method == "HEAD") + except (asyncio.TimeoutError, ConnectionError): + pass + except Exception as exc: + log.warning("HTTP handler error: %s", exc) + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + +def _write_response(writer, status, body, headers=None, head_only=False): + line = f"HTTP/1.1 {status.value} {status.phrase}\r\n" + hdr = "" + if headers: + for k, v in headers.items(): + hdr += f"{k}: {v}\r\n" + response = (line + hdr + "\r\n").encode() + if not head_only: + response += body if isinstance(body, bytes) else body.encode() + writer.write(response) + + +# --------------------------------------------------------------------------- +# WebSocket — broadcast gateway +# --------------------------------------------------------------------------- +ws_clients: Set[websockets.asyncio.server.ServerConnection] = set() + + +async def ws_handler(websocket: websockets.asyncio.server.ServerConnection): + ws_clients.add(websocket) + log.info("WS client connected from %s. Total: %d", websocket.remote_address, len(ws_clients)) try: async for message in websocket: - # Parse for logging/validation if it's JSON try: data = json.loads(message) msg_type = data.get("type", "unknown") - # Optional: log specific important message types - if msg_type in ["agent_register", "thought", "action"]: - logger.debug(f"Received {msg_type} from {addr}") + if msg_type in ("agent_register", "thought", "action"): + log.debug("WS %s from %s", msg_type, websocket.remote_address) except (json.JSONDecodeError, TypeError): pass # Broadcast to all OTHER clients - if not clients: - continue - disconnected = set() - # Create broadcast tasks, tracking which client each task targets - task_client_pairs = [] - for client in clients: + for client in ws_clients: if client != websocket and client.open: - task = asyncio.create_task(client.send(message)) - task_client_pairs.append((task, client)) + try: + await client.send(message) + except Exception: + disconnected.add(client) + ws_clients.difference_update(disconnected) - if task_client_pairs: - tasks = [pair[0] for pair in task_client_pairs] - results = await asyncio.gather(*tasks, return_exceptions=True) - for i, result in enumerate(results): - if isinstance(result, Exception): - target_client = task_client_pairs[i][1] - logger.error(f"Failed to send to client {target_client.remote_address}: {result}") - disconnected.add(target_client) - - if disconnected: - clients.difference_update(disconnected) - except websockets.exceptions.ConnectionClosed: - logger.debug(f"Connection closed by client {addr}") - except Exception as e: - logger.error(f"Error handling client {addr}: {e}") + pass + except Exception as exc: + log.error("WS handler error for %s: %s", websocket.remote_address, exc) finally: - clients.discard(websocket) - logger.info(f"Client disconnected {addr}. Total clients: {len(clients)}") + ws_clients.discard(websocket) + log.info("WS client disconnected. Total: %d", len(ws_clients)) + +# --------------------------------------------------------------------------- +# Main — run both servers concurrently +# --------------------------------------------------------------------------- async def main(): - """Main server loop with graceful shutdown.""" - logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}") - - # Set up signal handlers for graceful shutdown + # HTTP server + http_server = await asyncio.start_server(http_handler, HOST, HTTP_PORT) + log.info("HTTP server listening on http://%s:%d", HOST, HTTP_PORT) + + # WebSocket server + ws_server = await websockets.asyncio.server.serve(ws_handler, HOST, WS_PORT) + log.info("WebSocket server listening on ws://%s:%d", HOST, WS_PORT) + log.info("Nexus is live — open http://%s:%d in a browser", HOST, HTTP_PORT) + + # Graceful shutdown loop = asyncio.get_running_loop() stop = loop.create_future() - + def shutdown(): if not stop.done(): stop.set_result(None) @@ -95,29 +193,28 @@ async def main(): try: loop.add_signal_handler(sig, shutdown) except NotImplementedError: - # Signal handlers not supported on Windows pass - async with websockets.serve(broadcast_handler, HOST, PORT): - logger.info("Gateway is ready and listening.") - await stop - - logger.info("Shutting down Nexus WS gateway...") - # Close any remaining client connections (handlers may have already cleaned up) - remaining = {c for c in clients if c.open} + await stop + log.info("Shutting down...") + + http_server.close() + await http_server.wait_closed() + ws_server.close() + await ws_server.wait_closed() + + remaining = {c for c in ws_clients if c.open} if remaining: - logger.info(f"Closing {len(remaining)} active connections...") - close_tasks = [client.close() for client in remaining] - await asyncio.gather(*close_tasks, return_exceptions=True) - clients.clear() - - logger.info("Shutdown complete.") + await asyncio.gather(*(c.close() for c in remaining), return_exceptions=True) + ws_clients.clear() + log.info("Shutdown complete.") + if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: pass - except Exception as e: - logger.critical(f"Fatal server error: {e}") + except Exception as exc: + log.critical("Fatal: %s", exc) sys.exit(1)