Compare commits
1 Commits
mimo/code/
...
nexusburn/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d3d949d4 |
@@ -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"
|
||||||
|
|||||||
@@ -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
6
app.js
@@ -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);
|
||||||
|
|||||||
12
deploy.sh
12
deploy.sh
@@ -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"
|
||||||
|
|||||||
@@ -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
16
run.sh
Executable 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
233
server.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user