Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy (NEXUSBURN)
09d3d949d4 feat: unified HTTP+WS server for proper URL deployment
Some checks failed
CI / test (pull_request) Failing after 47s
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 50s
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.
2026-04-13 18:24:59 -04:00
7 changed files with 209 additions and 75 deletions

View File

@@ -27,8 +27,10 @@ jobs:
username: ${{ secrets.DEPLOY_USER }} username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
script: | 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 cd ~/the-nexus
git fetch origin main git fetch origin main
git reset --hard 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"

View File

@@ -11,11 +11,16 @@ COPY nexus/ nexus/
COPY server.py ./ COPY server.py ./
# Frontend assets referenced by index.html # 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 # Config/data
COPY portals.json vision.json robots.txt ./ COPY portals.json vision.json robots.txt ./
# Icons
COPY icons/ icons/
# Expose HTTP (static) and WebSocket
EXPOSE 8080
EXPOSE 8765 EXPOSE 8765
CMD ["python3", "server.py"] CMD ["python3", "server.py"]

6
app.js
View File

@@ -2188,7 +2188,11 @@ function connectHermes() {
} }
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 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}...`); console.log(`Connecting to Hermes at ${wsUrl}...`);
hermesWs = new WebSocket(wsUrl); hermesWs = new WebSocket(wsUrl);

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy.sh — spin up (or update) the Nexus staging environment # deploy.sh — spin up (or update) the Nexus staging environment
# Usage: ./deploy.sh — rebuild and restart nexus-main (port 4200) # Usage: ./deploy.sh — rebuild and restart nexus-main (HTTP :8080, WS :8765)
# ./deploy.sh staging — rebuild and restart nexus-staging (port 4201) # ./deploy.sh staging — rebuild and restart nexus-staging (HTTP :8081, WS :8766)
set -euo pipefail set -euo pipefail
SERVICE="${1:-nexus-main}" SERVICE="${1:-nexus-main}"
@@ -14,4 +14,12 @@ esac
echo "==> Deploying $SERVICE" echo "==> Deploying $SERVICE"
docker compose build "$SERVICE" docker compose build "$SERVICE"
docker compose up -d --force-recreate "$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" echo "==> Done. Container: $SERVICE"

View File

@@ -6,10 +6,12 @@ services:
container_name: nexus-main container_name: nexus-main
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080"
- "8765:8765" - "8765:8765"
nexus-staging: nexus-staging:
build: . build: .
container_name: nexus-staging container_name: nexus-staging
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8766:8765" - "8081:8080"
- "8766:8765"

16
run.sh Executable file
View File

@@ -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

233
server.py
View File

@@ -1,92 +1,190 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness. The Nexus — Unified HTTP + WebSocket server.
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
the body (Evennia/Morrowind), and the visualization surface. 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 asyncio
import json import json
import logging import logging
import mimetypes
import os
import signal import signal
import sys import sys
from http import HTTPStatus
from pathlib import Path
from typing import Set from typing import Set
# Branch protected file - see POLICY.md
import websockets import websockets
import websockets.asyncio.server
# ---------------------------------------------------------------------------
# Configuration # 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s', format="%(asctime)s [%(levelname)s] %(message)s",
datefmt='%Y-%m-%d %H:%M:%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): if method not in ("GET", "HEAD"):
"""Handles individual client connections and message broadcasting.""" _write_response(writer, HTTPStatus.METHOD_NOT_ALLOWED, b"Method Not Allowed")
clients.add(websocket) return
addr = websocket.remote_address
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}") # 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: try:
async for message in websocket: async for message in websocket:
# Parse for logging/validation if it's JSON
try: try:
data = json.loads(message) data = json.loads(message)
msg_type = data.get("type", "unknown") msg_type = data.get("type", "unknown")
# Optional: log specific important message types if msg_type in ("agent_register", "thought", "action"):
if msg_type in ["agent_register", "thought", "action"]: log.debug("WS %s from %s", msg_type, websocket.remote_address)
logger.debug(f"Received {msg_type} from {addr}")
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
# Broadcast to all OTHER clients # Broadcast to all OTHER clients
if not clients:
continue
disconnected = set() disconnected = set()
# Create broadcast tasks, tracking which client each task targets for client in ws_clients:
task_client_pairs = []
for client in clients:
if client != websocket and client.open: if client != websocket and client.open:
task = asyncio.create_task(client.send(message)) try:
task_client_pairs.append((task, client)) 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: except websockets.exceptions.ConnectionClosed:
logger.debug(f"Connection closed by client {addr}") pass
except Exception as e: except Exception as exc:
logger.error(f"Error handling client {addr}: {e}") log.error("WS handler error for %s: %s", websocket.remote_address, exc)
finally: finally:
clients.discard(websocket) ws_clients.discard(websocket)
logger.info(f"Client disconnected {addr}. Total clients: {len(clients)}") log.info("WS client disconnected. Total: %d", len(ws_clients))
# ---------------------------------------------------------------------------
# Main — run both servers concurrently
# ---------------------------------------------------------------------------
async def main(): async def main():
"""Main server loop with graceful shutdown.""" # HTTP server
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}") http_server = await asyncio.start_server(http_handler, HOST, HTTP_PORT)
log.info("HTTP server listening on http://%s:%d", HOST, HTTP_PORT)
# Set up signal handlers for graceful shutdown
# 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() loop = asyncio.get_running_loop()
stop = loop.create_future() stop = loop.create_future()
def shutdown(): def shutdown():
if not stop.done(): if not stop.done():
stop.set_result(None) stop.set_result(None)
@@ -95,29 +193,28 @@ async def main():
try: try:
loop.add_signal_handler(sig, shutdown) loop.add_signal_handler(sig, shutdown)
except NotImplementedError: except NotImplementedError:
# Signal handlers not supported on Windows
pass pass
async with websockets.serve(broadcast_handler, HOST, PORT): await stop
logger.info("Gateway is ready and listening.") log.info("Shutting down...")
await stop
http_server.close()
logger.info("Shutting down Nexus WS gateway...") await http_server.wait_closed()
# Close any remaining client connections (handlers may have already cleaned up) ws_server.close()
remaining = {c for c in clients if c.open} await ws_server.wait_closed()
remaining = {c for c in ws_clients if c.open}
if remaining: if remaining:
logger.info(f"Closing {len(remaining)} active connections...") await asyncio.gather(*(c.close() for c in remaining), return_exceptions=True)
close_tasks = [client.close() for client in remaining] ws_clients.clear()
await asyncio.gather(*close_tasks, return_exceptions=True) log.info("Shutdown complete.")
clients.clear()
logger.info("Shutdown complete.")
if __name__ == "__main__": if __name__ == "__main__":
try: try:
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
except Exception as e: except Exception as exc:
logger.critical(f"Fatal server error: {e}") log.critical("Fatal: %s", exc)
sys.exit(1) sys.exit(1)