openvibers/openvibe.live Commits
Deployment: real readiness gating, rollback, and the socket unit under version control
Changes
@@@ -1,154 +1,207 @@#!/usr/bin/env bash# ═══════════════════════════════════════════════════════════════--# OpenVibe.Live — Deploy Script--# Pulls latest from GitHub, notifies chat, and restarts the service.++# OpenVibe.Live — deploy#--# Usage (from the server):--# sudo /opt/openvibelive/deploy/scripts/deploy.sh++# Pull, validate, install units, restart, and PROVE the new process can serve a request before++# reporting success. Rolls back to the previous commit if it cannot.#--# Or remotely:--# ssh openvibe.tools "sudo /opt/openvibelive/deploy/scripts/deploy.sh"++# sudo /opt/openvibe.live/deploy/scripts/deploy.sh # deploy if there are new commits++# sudo /opt/openvibe.live/deploy/scripts/deploy.sh --restart # redeploy/restart even if current++# sudo /opt/openvibe.live/deploy/scripts/deploy.sh --force # discard local tracked changes++# DRY_RUN=1 ./deploy/scripts/deploy.sh # print the plan, change nothing++#++# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart++# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION# ═══════════════════════════════════════════════════════════════set -euo pipefailFORCE_DEPLOY=false++ALWAYS_RESTART=falsewhile [ "$#" -gt 0 ]; docase "$1" in-- --force|-f|--ignore-local-changes)-- FORCE_DEPLOY=true-- shift-- ;;-- --)-- shift-- break-- ;;-- *)-- echo "Usage: $0 [--force]"-- exit 1-- ;;++ --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;++ --restart) ALWAYS_RESTART=true; shift ;;++ --) shift; break ;;++ *) echo "Usage: $0 [--force] [--restart]"; exit 1 ;;esacdone--REPO_DIR="${REPO_DIR:-/opt/openvibelive}"--SERVICE="${SERVICE:-openvibelive}"--SERVICE_UNIT_SOURCE="${SERVICE_UNIT_SOURCE:-$REPO_DIR/deploy/systemd/${SERVICE}.service}"--SERVICE_UNIT_DEST="${SERVICE_UNIT_DEST:-/etc/systemd/system/${SERVICE}.service}"++# Defaults match AGENTS.md and deploy/systemd/. The old values (/opt/openvibelive,++# openvibelive) never matched the installed unit, so the unit-install step silently did nothing.++REPO_DIR="${REPO_DIR:-/opt/openvibe.live}"++SERVICE="${SERVICE:-openvibe-live}"++UNIT_DIR="${UNIT_DIR:-$REPO_DIR/deploy/systemd}"++SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"SITE_URL="${SITE_URL:-https://openvibe.live}"API_URL="${API_URL:-http://127.0.0.1:3000}"GIT_REMOTE="${GIT_REMOTE:-origin}"GIT_BRANCH="${GIT_BRANCH:-main}"--HEALTH_PATH="${HEALTH_PATH:-/api/health}"++READY_PATH="${READY_PATH:-/api/ready}"UPDATES_PATH="${UPDATES_PATH:-/updates}"BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"--DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-15}"++# Production boot is dominated by opening and migrating a large SQLite database; 15s was never++# enough and the old loop reported success regardless.++READY_TIMEOUT="${READY_TIMEOUT:-90}"++DRY_RUN="${DRY_RUN:-0}"++LOCK_FILE="${LOCK_FILE:-/tmp/openvibe-live-deploy.lock}"++++say() { echo "[Deploy] $*"; }++die() { echo "[Deploy] ✗ $*" >&2; exit "${2:-1}"; }++run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }++++# ── Only one deploy at a time ────────────────────────────────────────────────++# Two overlapping deploys can leave the working tree on one commit and the running process on++# another, which is the hardest kind of outage to diagnose.++exec 9>"$LOCK_FILE"++flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"--cd "$REPO_DIR"++cd "$REPO_DIR" || die "repo not found at $REPO_DIR"echo "╔══════════════════════════════════════╗"--echo "║ 🏕️ OpenVibe.Live Deploy Script ║"++echo "║ OpenVibe.Live — deploy ║"echo "╚══════════════════════════════════════╝"--echo ""++say "repo=$REPO_DIR service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"--# 1. Record current commit before pull--OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo "none")--echo "[Deploy] Current commit: ${OLD_HASH:0:8}"++OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)++say "current commit: ${OLD_HASH:0:8}"--# 2. Pull latest from GitHub++# ── Refuse to deploy over uncommitted work unless told to ────────────────────if [ "$FORCE_DEPLOY" = true ]; then-- echo "[Deploy] Force deploy enabled: discarding local tracked changes before pulling."-- echo "[Deploy] Untracked files will be preserved to avoid removing runtime downloads or temp artifacts."-- git reset --hard HEAD++ say "force: discarding local tracked changes (untracked files are kept)"++ run git reset --hard HEAD++elif ! git diff --quiet || ! git diff --cached --quiet; then++ die "working tree has uncommitted changes — commit them, or re-run with --force"fi--echo "[Deploy] Pulling from ${GIT_REMOTE} ${GIT_BRANCH}..."--git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only--NEW_HASH=$(git rev-parse HEAD)--echo "[Deploy] New commit: ${NEW_HASH:0:8}"----# 3. Update systemd service config if the repo includes one--UNIT_UPDATED=false--if [ -f "$SERVICE_UNIT_SOURCE" ]; then-- echo "[Deploy] Found service unit source: ${SERVICE_UNIT_SOURCE}"-- if [ ! -f "$SERVICE_UNIT_DEST" ] || ! cmp -s "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"; then-- echo "[Deploy] Installing updated service unit to ${SERVICE_UNIT_DEST}"-- sudo cp "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"-- sudo chmod 644 "$SERVICE_UNIT_DEST"-- UNIT_UPDATED=true-- else-- echo "[Deploy] Service unit is already up to date."-- fi-- if [ "$UNIT_UPDATED" = true ]; then-- echo "[Deploy] Reloading systemd daemon..."-- sudo systemctl daemon-reload-- sudo systemctl enable "$SERVICE" >/dev/null 2>&1 || true-- fi--else-- echo "[Deploy] No service unit file found at ${SERVICE_UNIT_SOURCE}, skipping systemd update."--fi++# ── Fetch first, decide second ───────────────────────────────────────────────++say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"++run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"++TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")--# 4. Check if there are actually new commits--if [ "$OLD_HASH" = "$NEW_HASH" ]; then-- echo "[Deploy] Already up to date — no new commits."-- echo "[Deploy] Restarting service anyway..."-- sudo systemctl restart "$SERVICE"-- echo "[Deploy] Done. ✅"++if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then++ # The old script restarted here anyway — a free outage for no change at all.++ say "already at ${TARGET_HASH:0:8}; nothing to deploy."++ say "pass --restart if you actually want to bounce the service."exit 0fi--# 5. Get commit log between old and new--COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${NEW_HASH}" 2>/dev/null || echo "Update deployed")--COMMIT_COUNT=$(echo "$COMMIT_LOG" | wc -l | tr -d ' ')--echo "[Deploy] ${COMMIT_COUNT} new commit(s):"--echo "$COMMIT_LOG"--echo ""----# 5. Build a summary for the chat notification (first 3 lines max)--SUMMARY_LINES=$(echo "$COMMIT_LOG" | head -3)--if [ "$COMMIT_COUNT" -gt 3 ]; then-- SUMMARY="${SUMMARY_LINES}--...and $((COMMIT_COUNT - 3)) more"++CHANGED_FILES=""++if [ "$OLD_HASH" != "$TARGET_HASH" ]; then++ CHANGED_FILES=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)++ COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")++ COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)++ say "$COMMIT_COUNT new commit(s):"++ printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'else-- SUMMARY="$SUMMARY_LINES"++ COMMIT_LOG="Restart requested"; COMMIT_COUNT=0++fi++++# ── Validate BEFORE interrupting anything ────────────────────────────────────++# Everything below happens while the old process is still serving traffic.++say "validating the target revision…"++run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || {++ [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2++}++NEW_HASH=$(git rev-parse HEAD)++++# Syntax-check every JS file the release touches. A parse error caught here costs nothing; the++# same error caught by systemd costs an outage plus a rollback.++if [ -n "$CHANGED_FILES" ]; then++ CHECKED=0++ while IFS= read -r f; do++ case "$f" in++ *.js) [ -f "$f" ] || continue++ node --check "$f" >/dev/null 2>&1 || die "syntax error in $f" 2++ CHECKED=$((CHECKED+1)) ;;++ esac++ done <<< "$CHANGED_FILES"++ say "syntax-checked $CHECKED changed JS file(s)"fi--# Single-line summary for the chat message--FIRST_LINE=$(echo "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')--if [ "$COMMIT_COUNT" -eq 1 ]; then-- CHAT_SUMMARY="Update: ${FIRST_LINE}"++# Dependencies: only when the lockfile actually moved. npm ci rebuilds native modules++# (better-sqlite3, mediasoup, sharp, onnxruntime), which is slow and must not run on every deploy.++if printf '%s\n' "$CHANGED_FILES" | grep -qx 'package-lock.json\|package.json'; then++ say "lockfile changed — installing dependencies (service still running)…"++ run npm ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2else-- CHAT_SUMMARY="${COMMIT_COUNT} updates deployed — ${FIRST_LINE}"++ say "dependencies unchanged — skipping npm ci"fi--# 6. Send update notification to chat (before restart)--echo "[Deploy] Notifying chat..."--UPDATES_URL="${SITE_URL}${UPDATES_PATH}"--NOTIFY_URL="${API_URL}${BROADCAST_ENDPOINT}"--# Use the admin broadcast endpoint if we have an admin token, otherwise skip--# The server will also send a restart message automatically on SIGTERM--curl -sf -X POST "${NOTIFY_URL}" \++# ── systemd units ────────────────────────────────────────────────────────────++UNITS_CHANGED=false++install_unit() {++ local src="$1" dest="$2"++ [ -f "$src" ] || return 0++ if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then++ say "installing $(basename "$dest")"++ run install -m 0644 -D "$src" "$dest"++ UNITS_CHANGED=true++ fi++}++install_unit "$UNIT_DIR/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"++install_unit "$UNIT_DIR/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"++install_unit "$UNIT_DIR/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"++if [ "$UNITS_CHANGED" = true ]; then++ run systemctl daemon-reload++ run systemctl enable "${SERVICE}.socket" >/dev/null 2>&1 || true++ run systemctl enable "$SERVICE" >/dev/null 2>&1 || true++fi++++if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed."; exit 0; fi++++# ── Tell chat, then restart ──────────────────────────────────────────────────++FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')++CHAT_SUMMARY=$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")++curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \-H "Content-Type: application/json" \-H "Authorization: Bearer ${ADMIN_TOKEN:-}" \-- -d "{-- \"type\": \"update\",-- \"summary\": $(echo "$CHAT_SUMMARY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'),-- \"url\": \"${UPDATES_URL}\"-- }" 2>/dev/null && echo "[Deploy] Chat notified ✅" || echo "[Deploy] Chat notification skipped (no admin token or server down)"----# 7. Restart the service (this triggers graceful shutdown → server_restart message)--echo "[Deploy] Restarting ${SERVICE}..."--sudo systemctl restart "$SERVICE"----# 8. Wait for service to come back up--echo -n "[Deploy] Waiting for server..."--for i in $(seq 1 "${DEPLOY_TIMEOUT}"); do-- sleep 1-- if curl -sf "${API_URL}${HEALTH_PATH}" > /dev/null 2>&1; then-- echo " up! ✅"-- break-- fi-- echo -n "."--done++ -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$CHAT_SUMMARY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \++ >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"++++# The socket unit, when present, holds the listener across the restart so connections queue++# instead of being refused. It does NOT preserve established WebSocket/WHIP/RTMP sessions.++if systemctl list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && systemctl is-enabled "${SERVICE}.socket" >/dev/null 2>&1; then++ systemctl is-active --quiet "${SERVICE}.socket" || systemctl start "${SERVICE}.socket" || true++ say "socket unit active — HTTP connections will queue through the restart"++else++ say "no socket unit active — expect brief connection refusals during restart"++fi++++say "restarting ${SERVICE}…"++START_TS=$(date +%s)++systemctl restart "$SERVICE"++++# ── Readiness gate that is actually a gate ───────────────────────────────────++# /api/health answers "a process is listening", which is true long before the database is open.++# /api/ready answers "this process can serve a real request". The old loop polled health and then++# printed success whether or not it ever succeeded.++wait_ready() {++ local deadline=$(( $(date +%s) + READY_TIMEOUT ))++ while [ "$(date +%s)" -lt "$deadline" ]; do++ if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi++ systemctl is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }++ sleep 1; printf '.'++ done++ printf '\n'; return 1++}++printf '[Deploy] waiting for readiness'++if wait_ready; then++ printf '\n'++ say "ready after $(( $(date +%s) - START_TS ))s"++else++ say "✗ not ready within ${READY_TIMEOUT}s — rolling back to ${OLD_HASH:0:8}"++ journalctl -u "$SERVICE" -n 40 --no-pager | sed 's/^/ /' || true++ git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4++ systemctl restart "$SERVICE" || die "ROLLBACK FAILED: service would not restart" 4++ printf '[Deploy] waiting for rollback'++ if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi++ die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4++fi++++# ── Post-conditions ──────────────────────────────────────────────────────────++SERVED=$(curl -sf --max-time 5 "${API_URL}/api/health" || echo '')++[ -n "$SERVED" ] || die "health endpoint not responding after readiness" 3--echo ""--echo "[Deploy] Deployment complete! 🎉"--echo "[Deploy] ${COMMIT_COUNT} commit(s) deployed: ${OLD_HASH:0:8} → ${NEW_HASH:0:8}"++say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"++say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."++say "done ✅"oold mode 100644nnew mode 100755@@@ -1,32 +1,89 @@--#!/usr/bin/env sh--set -eu++#!/usr/bin/env bash++# ═══════════════════════════════════════════════════════════════++# OpenVibe.Live — post-deploy verification++#++# Every check reports pass or fail and the script exits non-zero if any REQUIRED check failed.++# The previous version ended every command with `|| true`, so it always exited 0 — it printed++# information but could never tell you the deploy was broken.++#++# sudo ./post-deploy-check.sh [domain] [service]++#++# Exit: 0 all required checks passed · 1 one or more required checks failed++# ═══════════════════════════════════════════════════════════════++set -uo pipefail # deliberately NOT -e: we want to run every check, then report.DOMAIN="${1:-openvibe.live}"--SERVICE="${2:-openvibelive}"++SERVICE="${2:-openvibe-live}"++API_URL="${API_URL:-http://127.0.0.1:3000}"--echo "== systemd =="--sudo systemctl status "$SERVICE" --no-pager || true++FAILED=0++pass() { printf ' \033[32m✓\033[0m %s\n' "$1"; }++fail() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAILED=$((FAILED+1)); }++warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }++sect() { printf '\n== %s ==\n' "$1"; }--echo-- echo "== nginx test =="--sudo nginx -t++sect "service"++if systemctl is-active --quiet "$SERVICE"; then pass "$SERVICE is active"++else fail "$SERVICE is NOT active"; fi++if systemctl is-enabled --quiet "$SERVICE" 2>/dev/null; then pass "$SERVICE is enabled at boot"++else warn "$SERVICE is not enabled at boot"; fi--echo-- echo "== health check =="--curl -fsS "https://$DOMAIN/api/health" || true++sect "socket activation"++if systemctl list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && systemctl is-active --quiet "${SERVICE}.socket"; then++ pass "${SERVICE}.socket active — restarts queue connections instead of refusing them"++else++ warn "no active socket unit — a restart will refuse connections for the length of boot"++fi--echo-- echo "== listening ports =="--ss -tulpn | grep -E ':(80|443|1935|9935|9710|9711|4443|3000)\b' || true++sect "readiness"++# Readiness, not liveness: /api/ready only returns 200 once the database answers and boot is done.++READY_BODY=$(curl -sf --max-time 10 "${API_URL}/api/ready" 2>/dev/null || echo '')++if [ -n "$READY_BODY" ]; then pass "local /api/ready -> 200"++else fail "local /api/ready did not return 200 (process up but not serving)"; fi--echo-- echo "== ufw =="--sudo ufw status || true++sect "public reachability"++CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "https://$DOMAIN/api/health" || echo 000)++if [ "$CODE" = "200" ]; then pass "https://$DOMAIN/api/health -> 200"++else fail "https://$DOMAIN/api/health -> $CODE"; fi++HOME_CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://$DOMAIN/" || echo 000)++if [ "$HOME_CODE" = "200" ]; then pass "https://$DOMAIN/ -> 200"++else fail "https://$DOMAIN/ -> $HOME_CODE"; fi--echo-- echo "== fail2ban =="--sudo fail2ban-client status || true++sect "asset versioning"++# A versioned asset must exist at the exact URL the freshly-served HTML asks for. If HTML and++# assets ever disagree the page half-loads, which is worse than being plainly down.++HTML=$(curl -s --max-time 20 "https://$DOMAIN/" || echo '')++MISSING=0; CHECKED=0++while IFS= read -r asset; do++ [ -n "$asset" ] || continue++ CHECKED=$((CHECKED+1))++ AC=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://$DOMAIN$asset" || echo 000)++ [ "$AC" = "200" ] || { fail "asset referenced by HTML is missing: $asset ($AC)"; MISSING=$((MISSING+1)); }++done <<< "$(printf '%s' "$HTML" | grep -oE '(src|href)="/(js|css)/[^"]+"' | sed -E 's/^(src|href)="//; s/"$//' | sort -u | head -40)"++[ "$CHECKED" -gt 0 ] && [ "$MISSING" -eq 0 ] && pass "$CHECKED referenced JS/CSS assets all resolve"++[ "$CHECKED" -eq 0 ] && warn "could not parse asset references from HTML"--echo-- echo "== recent service logs =="--journalctl -u "$SERVICE" -n 50 --no-pager || true++sect "nginx"++if sudo nginx -t >/dev/null 2>&1; then pass "nginx config valid"++else fail "nginx config INVALID"; sudo nginx -t 2>&1 | sed 's/^/ /'; fi++++sect "listening ports"++PORTS=$(ss -tulpn 2>/dev/null | grep -Eo ':(80|443|1935|9935|3000)\b' | sort -u | tr '\n' ' ')++[ -n "$PORTS" ] && pass "listening on:$PORTS" || warn "could not read listening ports"++++sect "firewall / fail2ban (advisory)"++sudo ufw status 2>/dev/null | head -1 | sed 's/^/ /' || warn "ufw unavailable"++sudo fail2ban-client status 2>/dev/null | head -2 | sed 's/^/ /' || warn "fail2ban unavailable"++++sect "recent errors in the log"++ERRS=$(journalctl -u "$SERVICE" --since '5 minutes ago' --no-pager 2>/dev/null | grep -icE 'error|unhandled|ECONNREFUSED|TypeError' || true)++if [ "${ERRS:-0}" -gt 20 ]; then fail "$ERRS error lines in the last 5 minutes"; journalctl -u "$SERVICE" -n 15 --no-pager | sed 's/^/ /'++elif [ "${ERRS:-0}" -gt 0 ]; then warn "$ERRS error line(s) in the last 5 minutes"++else pass "no error lines in the last 5 minutes"; fi++++printf '\n'++if [ "$FAILED" -gt 0 ]; then++ printf '\033[31m%d required check(s) FAILED\033[0m\n' "$FAILED"++ exit 1++fi++printf '\033[32mAll required checks passed\033[0m\n'nnew file mode 100644@@@ -0,0 +1,5 @@++# Bind the service to the socket unit that owns its listener. Installed as a drop-in so the main++# unit stays usable on a box that has not been switched over to socket activation.++[Unit]++Requires=openvibe-live.socket++After=openvibe-live.socketnnew file mode 100644@@@ -0,0 +1,24 @@++# OpenVibe.Live — listening socket, owned by systemd rather than by the process.++#++# nginx proxies straight to 127.0.0.1:3000. Without this, every restart closes that listener and++# nginx gets ECONNREFUSED for the whole boot — which reaches users as a Cloudflare 502. With the++# socket held here, connections that arrive mid-restart wait in the kernel accept queue and are++# served as soon as the new process calls accept().++#++# What this does NOT do: it does not preserve established connections. Open WebSockets, WHIP++# sessions, RTMP ingests and WebRTC transports all live in the process and die with it. This buys++# clean HTTP continuity, nothing more — see deploy/README.md.++[Unit]++Description=OpenVibe.Live HTTP socket++PartOf=openvibe-live.service++++[Socket]++ListenStream=0.0.0.0:3000++NoDelay=true++# Node's libuv expects a non-blocking descriptor.++NonBlocking=true++# Match the listen backlog the process would have used itself.++Backlog=511++++[Install]++WantedBy=sockets.target@@@ -49,15 +49,65 @@@keyframes tourDot { 0%, 100% { opacity: 0.5; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.15); } }.tour-core-wrap { display: grid; place-items: center; position: relative; z-index: 1; }--.tour-core { position: relative; display: grid; justify-items: center; gap: 4px; padding: 18px 20px 16px; border-radius: 20px; text-align: center; width: 100%; max-width: 300px;-- background: radial-gradient(90% 90% at 50% 0%, color-mix(in srgb, var(--accent) 35%, transparent), transparent 70%), color-mix(in srgb, var(--bg-card) 85%, transparent);-- border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent); box-shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 18px 40px rgba(0,0,0,0.35), 0 0 40px color-mix(in srgb, var(--accent) 25%, transparent); }--.tour-core b { font-size: 1.02rem; font-weight: 900; }--.tour-core small { font-size: 0.68rem; color: var(--text-secondary); font-weight: 600; line-height: 1.4; }--.tour-core-logo { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-size: 1.15rem; color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #22d3ee)); box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 45%, transparent); margin-bottom: 4px; }--.tour-core-ring { position: absolute; left: 50%; top: 50%; width: 120%; height: 120%; border-radius: 26px; border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); transform: translate(-50%, -50%); animation: tourRing 3.2s ease-out infinite; pointer-events: none; }--.tour-core-ring--2 { animation-delay: 1.6s; }--@keyframes tourRing { 0% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.7; } 100% { transform: translate(-50%, -50%) scale(1.12); opacity: 0; } }++/* ── The hub ─────────────────────────────────────────────────────────────────++ This was a bordered card sitting in the middle of the diagram, so the wires ran *into a box*++ instead of into OpenVibe. The box is gone: the hub is now the glowing mark itself, with the++ name and the feature line as free-standing text beneath it, and the wires converge on the++ glow. The pulse rings are circles centred on the mark rather than rounded rectangles around a++ panel, so they read as signal radiating from the logo. */++.tour-core {++ position: relative; display: grid; justify-items: center; gap: 3px;++ padding: 4px 14px 2px; text-align: center; width: 100%; max-width: 320px;++ background: none; border: 0; box-shadow: none;++}++/* A soft pool of light behind the mark, in place of the old panel. */++.tour-core::before {++ content: ''; position: absolute; left: 50%; top: 6px; width: 190px; height: 190px;++ transform: translate(-50%, -22%); border-radius: 50%; pointer-events: none; z-index: -1;++ background: radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent) 0%, color-mix(in srgb, var(--accent) 10%, transparent) 38%, transparent 68%);++ animation: tourCoreBreathe 5.6s ease-in-out infinite;++}++@keyframes tourCoreBreathe { 0%, 100% { opacity: 0.75; transform: translate(-50%, -22%) scale(1); } 50% { opacity: 1; transform: translate(-50%, -22%) scale(1.07); } }++.tour-core b {++ font-size: 1.06rem; font-weight: 900; letter-spacing: -0.01em; position: relative;++ /* A slow sheen across the wordmark — the one place on the diagram that should catch the eye. */++ background: linear-gradient(100deg, var(--text-primary) 0%, var(--text-primary) 38%, #fff 48%, color-mix(in srgb, var(--accent) 45%, #fff) 56%, var(--text-primary) 66%, var(--text-primary) 100%);++ background-size: 280% 100%;++ -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;++ animation: tourCoreSheen 6.5s ease-in-out infinite;++ text-shadow: 0 0 24px color-mix(in srgb, var(--accent) 30%, transparent);++}++@keyframes tourCoreSheen { 0%, 62% { background-position: 130% 50%; } 100% { background-position: -30% 50%; } }++/* (.tour-core small removed — the chip grid below is the feature list.) */++/* The mark: a lit disc, not a flat chip. The inner highlight gives it a glass edge. */++.tour-core-logo {++ width: 52px; height: 52px; border-radius: 50%; display: grid; place-items: center;++ font-size: 1.25rem; color: #fff; margin-bottom: 5px; position: relative; z-index: 1;++ background:++ radial-gradient(70% 70% at 32% 26%, rgba(255,255,255,0.42), transparent 60%),++ linear-gradient(140deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #22d3ee));++ box-shadow:++ 0 0 0 1px color-mix(in srgb, #fff 22%, transparent),++ 0 0 26px color-mix(in srgb, var(--accent) 60%, transparent),++ 0 0 60px color-mix(in srgb, var(--accent) 30%, transparent),++ inset 0 -4px 10px rgba(0,0,0,0.25);++ animation: tourCoreGlow 3.8s ease-in-out infinite;++}++@keyframes tourCoreGlow {++ 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, #fff 22%, transparent), 0 0 26px color-mix(in srgb, var(--accent) 55%, transparent), 0 0 58px color-mix(in srgb, var(--accent) 26%, transparent), inset 0 -4px 10px rgba(0,0,0,0.25); }++ 50% { box-shadow: 0 0 0 1px color-mix(in srgb, #fff 34%, transparent), 0 0 34px color-mix(in srgb, var(--accent) 75%, transparent), 0 0 78px color-mix(in srgb, var(--accent) 38%, transparent), inset 0 -4px 10px rgba(0,0,0,0.25); }++}++/* Circles radiating from the mark, sized and centred on the logo rather than on the old panel. */++.tour-core-ring {++ position: absolute; left: 50%; top: 32px; width: 52px; height: 52px; border-radius: 50%;++ border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent);++ transform: translate(-50%, -50%); animation: tourRing 3.4s ease-out infinite; pointer-events: none;++}++.tour-core-ring--2 { animation-delay: 1.7s; }++@keyframes tourRing {++ 0% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.85; }++ 100% { transform: translate(-50%, -50%) scale(3.4); opacity: 0; }++}.tour-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 20px 0 0; }.tour-feat { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; color: var(--text-secondary);@@@ -77,8 +127,11 @@.tour-node-text small { display: none; }.tour-core { max-width: 340px; }}--@media (prefers-reduced-motion: reduce) { .tour-wire, .tour-node-dot, .tour-core-ring, .tour-feat { animation: none; } .tour-feat { opacity: 1; transform: none; } }++@media (prefers-reduced-motion: reduce) { .tour-wire, .tour-node-dot, .tour-core-ring, .tour-feat, .tour-core::before, .tour-core b, .tour-core-logo { animation: none; } .tour-feat { opacity: 1; transform: none; } }.rs-lite .tour::before, .rs-lite .tour-core-ring { display: none; }++/* Phones keep the glow but not the repainting gradients. */++.rs-lite .tour-core b { animation: none; -webkit-text-fill-color: var(--text-primary); background: none; }++.rs-lite .tour-core::before, .rs-lite .tour-core-logo { animation: none; }.rs-lite .tour-wire { animation: none; }/* ═══════════════════════════════════════════════════════════════@@@ -229,65 +229,141 @@.star-why-meta i { margin-right: 4px; }/* ── Compact Star card ───────────────────────────────────────────────────────-- The section opened as a full-width feature panel: big avatar, kicker, name, chips, a reason,-- a bio in two languages, an AI overview, a last-live note and — when they were on — a whole-- stream card. That is a lot of front page for one streamer. Collapsed it is now just who they-- are and the button to go watch them; everything else is one tap away, and the card animates-- between the two heights rather than jumping. */--.star-card.is-compact { padding: 14px 16px 16px; gap: 14px; align-items: center; }--.star-card.is-compact .star-avatar-wrap { width: 68px; height: 68px; }--.star-card.is-compact .star-avatar { width: 68px; height: 68px; font-size: 1.5rem; }--.star-card.is-compact .star-kicker { font-size: 0.6rem; margin-bottom: 2px; }--.star-card.is-compact .star-name { font-size: clamp(1.05rem, 3.4vw, 1.35rem); margin: 0 0 6px; }--.star-card.is-compact .star-chips { margin-bottom: 10px; }++ The first compact pass just shrank the feature layout, and it showed: a centred kicker and++ name over a bottom-aligned avatar, chips floating off to one side, and three buttons wrapping++ into a two-plus-one arrangement. Nothing lined up with anything.++++ Rebuilt as one deliberate shape — an avatar column and a left-aligned text column, with a++ single action row underneath both. Everything shares one left edge, the avatar is optically++ centred against the text block, and the three actions sit on one line at one height. */++.star-card.is-compact {++ display: grid;++ grid-template-columns: auto minmax(0, 1fr);++ grid-template-areas: "avatar text" "actions actions";++ align-items: center;++ gap: 10px 14px;++ padding: 15px 17px 16px;++ text-align: left;++}++.star-card.is-compact .star-avatar-wrap { grid-area: avatar; width: 62px; height: 62px; align-self: center; }++.star-card.is-compact .star-avatar { width: 62px; height: 62px; font-size: 1.45rem; }++.star-card.is-compact .star-body {++ grid-area: text; display: flex; flex-direction: column; align-items: flex-start;++ gap: 3px; min-width: 0;++}++/* The badge under the avatar duplicates the kicker and was the piece sticking out at an angle. */++.star-card.is-compact .star-badge,.star-card.is-compact .star-petals { display: none; }--.star-card.is-compact .star-actions { margin-top: 0; }--.star-card.is-compact .star-btn-watch { font-size: 0.86rem; padding: 8px 15px; }++.star-card.is-compact .star-kicker { font-size: 0.6rem; letter-spacing: 0.15em; margin: 0; }++.star-card.is-compact .star-name { font-size: clamp(1.1rem, 4.2vw, 1.5rem); margin: 0; }++.star-card.is-compact .star-name .star-jp { font-size: 0.5em; margin-top: 0; }++++/* Category and followers read as one meta line, not as competing pills. */++.star-card.is-compact .star-chips { gap: 5px; margin: 3px 0 0; }++.star-card.is-compact .star-chip {++ font-size: 0.68rem; padding: 2px 9px; border-radius: 999px;++ background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.12); color: var(--text-secondary);++}++.star-card.is-compact .star-chip.gold { color: #fde68a; background: rgba(251,191,36,0.1); border-color: rgba(251,191,36,0.3); }++++/* One action row, one height, one line. */++.star-card.is-compact .star-actions {++ grid-area: actions; display: flex; flex-wrap: wrap; align-items: center;++ gap: 8px; margin: 4px 0 0;++}++.star-card.is-compact .star-actions .btn,++.star-card.is-compact .star-expand {++ height: 38px; padding: 0 15px; font-size: 0.82rem; border-radius: 11px;++ display: inline-flex; align-items: center; gap: 8px; line-height: 1;++}++.star-card.is-compact .star-btn-watch { flex: 1 1 auto; min-width: 140px; justify-content: center; }++.star-card.is-compact .star-actions .btn-outline { flex: 0 1 auto; }++.star-card.is-compact .star-expand { flex: 0 0 auto; margin-left: auto; padding-left: 8px; }/* The disclosure. Fades up as the card grows — see animateHeightChange().[hidden] is a UA rule, so any author `display` here silently beats it and the panel neveractually hides. Stated explicitly, the same trap the hero strip fell into. */.star-more[hidden] { display: none !important; }--.star-more { display: block; }++.star-more { display: block; grid-column: 1 / -1; }++/* Expanded (non-compact) card: both blocks run the full width under the avatar and text. */++.star-card > .star-actions, .star-card > .star-more { grid-column: 1 / -1; }.star-more.is-entering { animation: starMoreIn 0.42s cubic-bezier(.2, 1, .3, 1); }@keyframes starMoreIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }++/* The avatar ring: a slow conic sweep rather than a static rainbow, so the card has one moving++ element instead of several competing ones. Paused off-screen by the viewport gate. */++.star-card.is-compact .star-avatar-ring {++ inset: -3px; border-radius: 50%;++ background: conic-gradient(from 0deg, #fbbf24, #f472b6, var(--accent), #fbbf24);++ animation: starRingSpin 6s linear infinite;++ opacity: 0.9;++}++@keyframes starRingSpin { to { transform: rotate(360deg); } }++.star-card.is-compact:hover .star-avatar-ring { opacity: 1; }++.star-card.is-compact .star-avatar { transition: transform 0.28s cubic-bezier(.2, 1.2, .3, 1); }++.star-card.is-compact:hover .star-avatar { transform: scale(1.04); }++.star-expand {-- display: inline-flex; align-items: center; gap: 8px;-- padding: 7px 14px 7px 8px; border-radius: 999px; cursor: pointer; font: inherit;-- font-size: 0.78rem; font-weight: 750;-- color: color-mix(in srgb, #fbbf24 80%, var(--text-primary));-- background: color-mix(in srgb, #fbbf24 10%, transparent);-- border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent);++ cursor: pointer; font: inherit; font-weight: 750;++ color: color-mix(in srgb, #fbbf24 82%, var(--text-primary));++ background: color-mix(in srgb, #fbbf24 9%, transparent);++ border: 1px solid color-mix(in srgb, #fbbf24 26%, transparent);transition: transform 0.2s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;}.star-expand:hover {transform: translateY(-1px);-- background: color-mix(in srgb, #fbbf24 18%, transparent);-- border-color: color-mix(in srgb, #fbbf24 55%, transparent);-- box-shadow: 0 6px 18px color-mix(in srgb, #fbbf24 22%, transparent);++ background: color-mix(in srgb, #fbbf24 17%, transparent);++ border-color: color-mix(in srgb, #fbbf24 50%, transparent);++ box-shadow: 0 6px 16px color-mix(in srgb, #fbbf24 20%, transparent);}.star-expand:active { transform: translateY(0) scale(0.985); }.star-expand:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; }.star-expand i {-- width: 20px; height: 20px; flex: none; border-radius: 50%; display: grid; place-items: center;-- font-size: 0.58rem; color: #1a1506; line-height: 1;++ width: 19px; height: 19px; flex: none; border-radius: 50%; display: grid; place-items: center;++ font-size: 0.55rem; color: #1a1506; line-height: 1;background: linear-gradient(140deg, #fbbf24, #f59e0b);transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);}.star-expand.is-open i { transform: rotate(180deg); }--.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 44vw; }++.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++++/* The gold border sweep is the card's one loud effect; calm it down when compact so it frames++ the card instead of shouting over it. */++.star-card.is-compact ~ .star-card-border::before,++.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }--@media (max-width: 640px) {-- .star-card.is-compact { padding: 12px 13px 14px; gap: 11px; grid-template-columns: auto 1fr; }-- .star-card.is-compact .star-avatar-wrap, .star-card.is-compact .star-avatar { width: 54px; height: 54px; }++/* Wide enough for one line: avatar, who they are, and the actions on the right. Below this the++ actions drop to their own row rather than squeezing. */++@media (min-width: 820px) {++ .star-card.is-compact {++ grid-template-columns: auto minmax(0, 1fr) auto;++ grid-template-areas: "avatar text actions";++ gap: 16px;++ }++ .star-card.is-compact .star-actions { margin: 0; justify-content: flex-end; flex-wrap: nowrap; }++ .star-card.is-compact .star-btn-watch { flex: 0 0 auto; min-width: 0; }++ .star-card.is-compact .star-expand { margin-left: 0; }++ .star-card.is-compact .star-more { grid-column: 1 / -1; }++}++++@media (max-width: 560px) {++ .star-card.is-compact { padding: 13px 13px 14px; gap: 9px 11px; }++ .star-card.is-compact .star-avatar-wrap,++ .star-card.is-compact .star-avatar { width: 52px; height: 52px; }.star-card.is-compact .star-avatar { font-size: 1.2rem; }-- .star-card.is-compact .star-actions { flex-wrap: wrap; gap: 7px; }-- .star-expand { font-size: 0.72rem; padding: 6px 11px 6px 6px; }++ .star-card.is-compact .star-actions .btn,++ .star-card.is-compact .star-expand { height: 36px; font-size: 0.78rem; padding: 0 12px; }++ /* On a phone the three actions stack two-then-one cleanly rather than at random. */++ .star-card.is-compact .star-btn-watch { flex: 1 1 100%; }++ .star-card.is-compact .star-expand { margin-left: 0; flex: 1 1 auto; justify-content: center; }++}++@media (max-width: 380px) {++ .star-card.is-compact .star-actions .btn-outline { flex: 1 1 100%; justify-content: center; }}@media (prefers-reduced-motion: reduce) {.star-more.is-entering { animation: none; }.star-card.is-swapping { transition: none; }-- .star-expand, .star-expand i { transition: none; }++ .star-expand, .star-expand i, .star-card.is-compact .star-avatar { transition: none; }++ .star-card.is-compact .star-avatar-ring { animation: none; }}@@@ -90,8 +90,8 @@<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="/css/style.css?v=194"><link rel="stylesheet" href="/css/home-fx.css?v=13">-- <link rel="stylesheet" href="/css/home-tour.css?v=7">-- <link rel="stylesheet" href="/css/i18n-star.css?v=7">++ <link rel="stylesheet" href="/css/home-tour.css?v=8">++ <link rel="stylesheet" href="/css/i18n-star.css?v=10"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"><link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null"><link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">@@@ -397,11 +397,10 @@</div><div class="home-cta-inner">-- <div class="cta-eyebrow">-- <span class="cta-live-dot"></span>-- HOME-GROWN & HOBBYIST-RUN — NOT ANOTHER CORPORATE STREAM SITE-- </div>--++ <!-- The all-caps eyebrow here ("HOME-GROWN & HOBBYIST-RUN — NOT ANOTHER CORPORATE++ STREAM SITE") was a louder restatement of the lede two lines below it, which++ already says the platform is built by one person who streams here. One of them++ is enough, and the lede is the one that reads like a human wrote it. --><h2 class="cta-hero">Streaming the way it <span class="cta-hero-grad">should</span> be.</h2><p class="cta-lede">OpenVibe.Live is a living platform built by <strong>one person who actually streams here</strong>, hangs out in chat, and ships your ideas. No boardroom, no algorithm games, no mystery bans — just an <strong>epic</strong> streaming stack packed with things the big sites flat-out don't have.@@@ -5131,7 +5130,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/live-notify.js?v=3" defer></script><script src="/js/ov-skeleton.js?v=1" defer></script><script src="/js/ov-odometer.js?v=3" defer></script>--<script src="/js/app.js?v=166" defer></script>++<script src="/js/app.js?v=167" defer></script><script src="/js/stream-player.js?v=22" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -5215,7 +5214,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/shared/footer.js?v=1" defer></script><script src="/js/footer-live.js?v=1" defer></script><script src="/js/home-fx.js?v=9" defer></script>--<script src="/js/home-tour.js?v=6" defer></script>++<script src="/js/home-tour.js?v=8" defer></script><script src="/js/ov-guide.js?v=2" defer></script><script src="/js/guides-live.js?v=6" defer></script>@@@ -9647,19 +9647,19 @@ async function loadHomeStar() {<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div><h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3><div class="star-chips">${chips}</div>-- <div class="star-more" id="star-more" hidden>-- ${why}-- ${bioHtml}-- ${overview}-- ${offline}-- </div>-- <div class="star-actions">++ </div>++ <div class="star-more" id="star-more" hidden>++ ${why}++ ${bioHtml}++ ${overview}++ ${offline}++ </div>++ <div class="star-actions"><a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a><a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a><button type="button" class="star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more"><i class="fa-solid fa-chevron-down"></i><span>More about ${name}</span></button>-- </div></div></div></div>`;@@@ -37,7 +37,8 @@el.innerHTML = `<section class="tour" aria-label="Stream from OpenVibe.Live and restream everywhere"><div class="tour-head">-- <div class="tour-kicker"><i class="fa-solid fa-satellite-dish"></i> Restream</div>++ <!-- The "RESTREAM" eyebrow that used to sit here restated the heading directly++ below it and the diagram directly below that. Three statements of one idea. --><h2>One stream. Everywhere.</h2><p>Go live here once and mirror it to every platform at the same time — your chat, emotes, sound commands, robot controls, VODs and clips stay in one place.</p></div>@@@ -52,7 +53,9 @@<span class="tour-core-ring"></span><span class="tour-core-ring tour-core-ring--2"></span><span class="tour-core-logo"><span class="ov-mark" data-size="30" style="color:#fff"></span></span><b>OpenVibe.Live</b>-- <small><1s latency · chat · emotes · sounds · controls · VOD · clips</small>++ <!-- The old feature line here ("<1s latency · chat · emotes · …") listed the same++ things the chip grid below spells out properly. Two statements of the same++ list, one of them abbreviated, is worse than one. The hub just names itself. --></div></div><div class="tour-col tour-col--dst">@@@ -673,30 +673,89 @@ app.get('/api/health', (req, res) => {});});++/**++ * GET /api/ready — readiness, which is not the same thing as liveness.++ *++ * /api/health answers "is a process listening". That is true from the moment the socket is++ * accepted, which during a deploy is *before* the database is open and before the request paths++ * this server actually serves will work. A deploy script that waits on /api/health therefore++ * declares success while the site is still returning errors.++ *++ * This answers "can this process serve a real request": the database responds, the schema is++ * initialised, and boot has completed. 503 until all of that is true, so a health gate can be a++ * genuine gate.++ */++let _bootComplete = false;++app.get('/api/ready', (req, res) => {++ res.set('Cache-Control', 'no-store');++ const checks = { boot: _bootComplete, db: false };++ try {++ // Cheapest possible proof that the connection is open and the schema exists.++ db.get('SELECT 1 AS ok');++ checks.db = true;++ } catch (err) {++ checks.dbError = String(err && err.message || err).slice(0, 200);++ }++ const ready = checks.boot && checks.db;++ res.status(ready ? 200 : 503).json({++ ready,++ checks,++ uptime: Math.round(process.uptime()),++ // Advisory only — a deploy gate should not wait on optional subsystems, which can be++ // legitimately unavailable (no mediasoup worker on a box that only relays RTMP, say).++ optional: {++ sfu: (() => { try { return !!webrtcSFU.isReady?.(); } catch { return null; } })(),++ media: (() => { try { return require('./media-client').lastOk === true; } catch { return null; } })(),++ },++ });++});++// ── Updates / Changelog ──────────────────────────────────────--const { execSync } = require('child_process');++const { execFile } = require('child_process');const REPO_DIR = path.resolve(__dirname, '..');/**-- * GET /api/updates — returns recent git commit history for the updates page.-- * Query params: ?limit=30 (default 30, max 100)++ * GET /api/updates — recent commit history for the updates page.++ *++ * This used to shell out with execSync on the request path. execSync blocks the entire event++ * loop: for however long git takes — up to its 5s timeout — no other request on the box is++ * served, no WebSocket frame is read, and no chat message is delivered. The home page fetches++ * this on every load, so a cold git call after a deploy stalled everyone at once.++ *++ * Now: one async read, memoised, shared by concurrent callers. The history only changes when we++ * deploy, so a minute of staleness is free.*/--app.get('/api/updates', (req, res) => {-- try {-- const limit = Math.min(Math.max(parseInt(req.query.limit) || 30, 1), 100);-- const raw = execSync(-- `git --no-pager log --pretty=format:'%H||%h||%s||%an||%aI' -${limit}`,-- { cwd: REPO_DIR, encoding: 'utf8', timeout: 5000 }-- );-- const commits = raw.trim().split('\n').filter(Boolean).map(line => {-- const [hash, short, subject, author, date] = line.split('||');-- return { hash, short, subject, author, date };-- });-- res.json({ commits });-- } catch (err) {-- console.error('[Updates] git log error:', err.message);-- res.status(500).json({ error: 'Failed to read update history' });++const UPDATES_TTL_MS = 60_000;++let _updatesCache = { at: 0, commits: null, inflight: null };++function readCommits(limit) {++ if (_updatesCache.commits && Date.now() - _updatesCache.at < UPDATES_TTL_MS) {++ return Promise.resolve(_updatesCache.commits);}++ // Coalesce: a burst of first-loads after a deploy must not spawn a git process each.++ if (_updatesCache.inflight) return _updatesCache.inflight;++ _updatesCache.inflight = new Promise((resolve) => {++ execFile('git', ['--no-pager', 'log', '--pretty=format:%H||%h||%s||%an||%aI', `-${limit}`],++ { cwd: REPO_DIR, encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024 },++ (err, stdout) => {++ _updatesCache.inflight = null;++ if (err) return resolve(_updatesCache.commits || null);++ const commits = String(stdout).trim().split('\n').filter(Boolean).map(line => {++ const [hash, short, subject, author, date] = line.split('||');++ return { hash, short, subject, author, date };++ });++ _updatesCache = { at: Date.now(), commits, inflight: null };++ resolve(commits);++ });++ });++ return _updatesCache.inflight;++}++app.get('/api/updates', async (req, res) => {++ const limit = Math.min(Math.max(parseInt(req.query.limit) || 30, 1), 100);++ // Always read the widest window we serve, so one cache entry answers every limit.++ const commits = await readCommits(100);++ if (!commits) return res.status(503).json({ error: 'Update history unavailable' });++ res.set('Cache-Control', 'public, max-age=60');++ res.json({ commits: commits.slice(0, limit) });});/**@@@ -1145,6 +1204,9 @@ async function start() {console.warn('[Server] WARNING: Mediasoup announcedIp is configured as a local address. External WebRTC clients may be unable to connect. Set MEDIASOUP_ANNOUNCED_IP to your public WHIP/WebRTC hostname.');}console.log('');++ // Flip readiness only once everything the request paths depend on is up. /api/ready++ // returns 503 before this line, which is what makes a deploy health gate meaningful.++ _bootComplete = true;console.log('[Server] Ready. Good vibes only. ▶');console.log('');@@@ -1469,6 +1531,10 @@ function shutdown() {// go-live until viewers refresh. Done up-front so RS has the whole shutdown window to propagate.try { const n = require('./integrations/rs-passthrough-relay').stopAll(); if (n) console.log(`[Server] Closed ${n} RobotStreamer passthrough(s)`); } catch { /* */ }++ // Stop advertising readiness immediately: from here on this process is draining, and anything++ // gating on /api/ready should see that before the socket actually closes.++ _bootComplete = false;++// Small delay to let the message reach clients before closing socketssetTimeout(() => {restreamManager.stopViewerCountPolling();
@@ -1,154 +1,207 @@
@@ -1,154 +1,207 @@
#!/usr/bin/env bash
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
-# OpenVibe.Live — Deploy Script
-# Pulls latest from GitHub, notifies chat, and restarts the service.
+# OpenVibe.Live — deploy
#
#
-# Usage (from the server):
-# sudo /opt/openvibelive/deploy/scripts/deploy.sh
+# Pull, validate, install units, restart, and PROVE the new process can serve a request before
+# reporting success. Rolls back to the previous commit if it cannot.
#
#
-# Or remotely:
-# ssh openvibe.tools "sudo /opt/openvibelive/deploy/scripts/deploy.sh"
+# sudo /opt/openvibe.live/deploy/scripts/deploy.sh # deploy if there are new commits
+# sudo /opt/openvibe.live/deploy/scripts/deploy.sh --restart # redeploy/restart even if current
+# sudo /opt/openvibe.live/deploy/scripts/deploy.sh --force # discard local tracked changes
+# DRY_RUN=1 ./deploy/scripts/deploy.sh # print the plan, change nothing
+#
+# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart
+# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
set -euo pipefail
FORCE_DEPLOY=false
FORCE_DEPLOY=false
+ALWAYS_RESTART=false
while [ "$#" -gt 0 ]; do
while [ "$#" -gt 0 ]; do
case "$1" in
case "$1" in
- --force|-f|--ignore-local-changes)
- FORCE_DEPLOY=true
- shift
- ;;
- --)
- shift
- break
- ;;
- *)
- echo "Usage: $0 [--force]"
- exit 1
- ;;
+ --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;
+ --restart) ALWAYS_RESTART=true; shift ;;
+ --) shift; break ;;
+ *) echo "Usage: $0 [--force] [--restart]"; exit 1 ;;
esac
esac
done
done
-REPO_DIR="${REPO_DIR:-/opt/openvibelive}"
-SERVICE="${SERVICE:-openvibelive}"
-SERVICE_UNIT_SOURCE="${SERVICE_UNIT_SOURCE:-$REPO_DIR/deploy/systemd/${SERVICE}.service}"
-SERVICE_UNIT_DEST="${SERVICE_UNIT_DEST:-/etc/systemd/system/${SERVICE}.service}"
+# Defaults match AGENTS.md and deploy/systemd/. The old values (/opt/openvibelive,
+# openvibelive) never matched the installed unit, so the unit-install step silently did nothing.
+REPO_DIR="${REPO_DIR:-/opt/openvibe.live}"
+SERVICE="${SERVICE:-openvibe-live}"
+UNIT_DIR="${UNIT_DIR:-$REPO_DIR/deploy/systemd}"
+SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
SITE_URL="${SITE_URL:-https://openvibe.live}"
SITE_URL="${SITE_URL:-https://openvibe.live}"
API_URL="${API_URL:-http://127.0.0.1:3000}"
API_URL="${API_URL:-http://127.0.0.1:3000}"
GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_BRANCH="${GIT_BRANCH:-main}"
GIT_BRANCH="${GIT_BRANCH:-main}"
-HEALTH_PATH="${HEALTH_PATH:-/api/health}"
+READY_PATH="${READY_PATH:-/api/ready}"
UPDATES_PATH="${UPDATES_PATH:-/updates}"
UPDATES_PATH="${UPDATES_PATH:-/updates}"
BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"
BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"
-DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-15}"
+# Production boot is dominated by opening and migrating a large SQLite database; 15s was never
+# enough and the old loop reported success regardless.
+READY_TIMEOUT="${READY_TIMEOUT:-90}"
+DRY_RUN="${DRY_RUN:-0}"
+LOCK_FILE="${LOCK_FILE:-/tmp/openvibe-live-deploy.lock}"
+
+say() { echo "[Deploy] $*"; }
+die() { echo "[Deploy] ✗ $*" >&2; exit "${2:-1}"; }
+run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }
+
+# ── Only one deploy at a time ────────────────────────────────────────────────
+# Two overlapping deploys can leave the working tree on one commit and the running process on
+# another, which is the hardest kind of outage to diagnose.
+exec 9>"$LOCK_FILE"
+flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"
-cd "$REPO_DIR"
+cd "$REPO_DIR" || die "repo not found at $REPO_DIR"
echo "╔══════════════════════════════════════╗"
echo "╔══════════════════════════════════════╗"
-echo "║ 🏕️ OpenVibe.Live Deploy Script ║"
+echo "║ OpenVibe.Live — deploy ║"
echo "╚══════════════════════════════════════╝"
echo "╚══════════════════════════════════════╝"
-echo ""
+say "repo=$REPO_DIR service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"
-# 1. Record current commit before pull
-OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo "none")
-echo "[Deploy] Current commit: ${OLD_HASH:0:8}"
+OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)
+say "current commit: ${OLD_HASH:0:8}"
-# 2. Pull latest from GitHub
+# ── Refuse to deploy over uncommitted work unless told to ────────────────────
if [ "$FORCE_DEPLOY" = true ]; then
if [ "$FORCE_DEPLOY" = true ]; then
- echo "[Deploy] Force deploy enabled: discarding local tracked changes before pulling."
- echo "[Deploy] Untracked files will be preserved to avoid removing runtime downloads or temp artifacts."
- git reset --hard HEAD
+ say "force: discarding local tracked changes (untracked files are kept)"
+ run git reset --hard HEAD
+elif ! git diff --quiet || ! git diff --cached --quiet; then
+ die "working tree has uncommitted changes — commit them, or re-run with --force"
fi
fi
-echo "[Deploy] Pulling from ${GIT_REMOTE} ${GIT_BRANCH}..."
-git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only
-NEW_HASH=$(git rev-parse HEAD)
-echo "[Deploy] New commit: ${NEW_HASH:0:8}"
-
-# 3. Update systemd service config if the repo includes one
-UNIT_UPDATED=false
-if [ -f "$SERVICE_UNIT_SOURCE" ]; then
- echo "[Deploy] Found service unit source: ${SERVICE_UNIT_SOURCE}"
- if [ ! -f "$SERVICE_UNIT_DEST" ] || ! cmp -s "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"; then
- echo "[Deploy] Installing updated service unit to ${SERVICE_UNIT_DEST}"
- sudo cp "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"
- sudo chmod 644 "$SERVICE_UNIT_DEST"
- UNIT_UPDATED=true
- else
- echo "[Deploy] Service unit is already up to date."
- fi
- if [ "$UNIT_UPDATED" = true ]; then
- echo "[Deploy] Reloading systemd daemon..."
- sudo systemctl daemon-reload
- sudo systemctl enable "$SERVICE" >/dev/null 2>&1 || true
- fi
-else
- echo "[Deploy] No service unit file found at ${SERVICE_UNIT_SOURCE}, skipping systemd update."
-fi
+# ── Fetch first, decide second ───────────────────────────────────────────────
+say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
+run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
+TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")
-# 4. Check if there are actually new commits
-if [ "$OLD_HASH" = "$NEW_HASH" ]; then
- echo "[Deploy] Already up to date — no new commits."
- echo "[Deploy] Restarting service anyway..."
- sudo systemctl restart "$SERVICE"
- echo "[Deploy] Done. ✅"
+if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then
+ # The old script restarted here anyway — a free outage for no change at all.
+ say "already at ${TARGET_HASH:0:8}; nothing to deploy."
+ say "pass --restart if you actually want to bounce the service."
exit 0
exit 0
fi
fi
-# 5. Get commit log between old and new
-COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${NEW_HASH}" 2>/dev/null || echo "Update deployed")
-COMMIT_COUNT=$(echo "$COMMIT_LOG" | wc -l | tr -d ' ')
-echo "[Deploy] ${COMMIT_COUNT} new commit(s):"
-echo "$COMMIT_LOG"
-echo ""
-
-# 5. Build a summary for the chat notification (first 3 lines max)
-SUMMARY_LINES=$(echo "$COMMIT_LOG" | head -3)
-if [ "$COMMIT_COUNT" -gt 3 ]; then
- SUMMARY="${SUMMARY_LINES}
-...and $((COMMIT_COUNT - 3)) more"
+CHANGED_FILES=""
+if [ "$OLD_HASH" != "$TARGET_HASH" ]; then
+ CHANGED_FILES=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)
+ COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")
+ COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
+ say "$COMMIT_COUNT new commit(s):"
+ printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
else
else
- SUMMARY="$SUMMARY_LINES"
+ COMMIT_LOG="Restart requested"; COMMIT_COUNT=0
+fi
+
+# ── Validate BEFORE interrupting anything ────────────────────────────────────
+# Everything below happens while the old process is still serving traffic.
+say "validating the target revision…"
+run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || {
+ [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2
+}
+NEW_HASH=$(git rev-parse HEAD)
+
+# Syntax-check every JS file the release touches. A parse error caught here costs nothing; the
+# same error caught by systemd costs an outage plus a rollback.
+if [ -n "$CHANGED_FILES" ]; then
+ CHECKED=0
+ while IFS= read -r f; do
+ case "$f" in
+ *.js) [ -f "$f" ] || continue
+ node --check "$f" >/dev/null 2>&1 || die "syntax error in $f" 2
+ CHECKED=$((CHECKED+1)) ;;
+ esac
+ done <<< "$CHANGED_FILES"
+ say "syntax-checked $CHECKED changed JS file(s)"
fi
fi
-# Single-line summary for the chat message
-FIRST_LINE=$(echo "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
-if [ "$COMMIT_COUNT" -eq 1 ]; then
- CHAT_SUMMARY="Update: ${FIRST_LINE}"
+# Dependencies: only when the lockfile actually moved. npm ci rebuilds native modules
+# (better-sqlite3, mediasoup, sharp, onnxruntime), which is slow and must not run on every deploy.
+if printf '%s\n' "$CHANGED_FILES" | grep -qx 'package-lock.json\|package.json'; then
+ say "lockfile changed — installing dependencies (service still running)…"
+ run npm ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2
else
else
- CHAT_SUMMARY="${COMMIT_COUNT} updates deployed — ${FIRST_LINE}"
+ say "dependencies unchanged — skipping npm ci"
fi
fi
-# 6. Send update notification to chat (before restart)
-echo "[Deploy] Notifying chat..."
-UPDATES_URL="${SITE_URL}${UPDATES_PATH}"
-NOTIFY_URL="${API_URL}${BROADCAST_ENDPOINT}"
-# Use the admin broadcast endpoint if we have an admin token, otherwise skip
-# The server will also send a restart message automatically on SIGTERM
-curl -sf -X POST "${NOTIFY_URL}" \
+# ── systemd units ────────────────────────────────────────────────────────────
+UNITS_CHANGED=false
+install_unit() {
+ local src="$1" dest="$2"
+ [ -f "$src" ] || return 0
+ if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then
+ say "installing $(basename "$dest")"
+ run install -m 0644 -D "$src" "$dest"
+ UNITS_CHANGED=true
+ fi
+}
+install_unit "$UNIT_DIR/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
+install_unit "$UNIT_DIR/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
+install_unit "$UNIT_DIR/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
+if [ "$UNITS_CHANGED" = true ]; then
+ run systemctl daemon-reload
+ run systemctl enable "${SERVICE}.socket" >/dev/null 2>&1 || true
+ run systemctl enable "$SERVICE" >/dev/null 2>&1 || true
+fi
+
+if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed."; exit 0; fi
+
+# ── Tell chat, then restart ──────────────────────────────────────────────────
+FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
+CHAT_SUMMARY=$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")
+curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN:-}" \
-H "Authorization: Bearer ${ADMIN_TOKEN:-}" \
- -d "{
- \"type\": \"update\",
- \"summary\": $(echo "$CHAT_SUMMARY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'),
- \"url\": \"${UPDATES_URL}\"
- }" 2>/dev/null && echo "[Deploy] Chat notified ✅" || echo "[Deploy] Chat notification skipped (no admin token or server down)"
-
-# 7. Restart the service (this triggers graceful shutdown → server_restart message)
-echo "[Deploy] Restarting ${SERVICE}..."
-sudo systemctl restart "$SERVICE"
-
-# 8. Wait for service to come back up
-echo -n "[Deploy] Waiting for server..."
-for i in $(seq 1 "${DEPLOY_TIMEOUT}"); do
- sleep 1
- if curl -sf "${API_URL}${HEALTH_PATH}" > /dev/null 2>&1; then
- echo " up! ✅"
- break
- fi
- echo -n "."
-done
+ -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$CHAT_SUMMARY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \
+ >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"
+
+# The socket unit, when present, holds the listener across the restart so connections queue
+# instead of being refused. It does NOT preserve established WebSocket/WHIP/RTMP sessions.
+if systemctl list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && systemctl is-enabled "${SERVICE}.socket" >/dev/null 2>&1; then
+ systemctl is-active --quiet "${SERVICE}.socket" || systemctl start "${SERVICE}.socket" || true
+ say "socket unit active — HTTP connections will queue through the restart"
+else
+ say "no socket unit active — expect brief connection refusals during restart"
+fi
+
+say "restarting ${SERVICE}…"
+START_TS=$(date +%s)
+systemctl restart "$SERVICE"
+
+# ── Readiness gate that is actually a gate ───────────────────────────────────
+# /api/health answers "a process is listening", which is true long before the database is open.
+# /api/ready answers "this process can serve a real request". The old loop polled health and then
+# printed success whether or not it ever succeeded.
+wait_ready() {
+ local deadline=$(( $(date +%s) + READY_TIMEOUT ))
+ while [ "$(date +%s)" -lt "$deadline" ]; do
+ if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi
+ systemctl is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }
+ sleep 1; printf '.'
+ done
+ printf '\n'; return 1
+}
+printf '[Deploy] waiting for readiness'
+if wait_ready; then
+ printf '\n'
+ say "ready after $(( $(date +%s) - START_TS ))s"
+else
+ say "✗ not ready within ${READY_TIMEOUT}s — rolling back to ${OLD_HASH:0:8}"
+ journalctl -u "$SERVICE" -n 40 --no-pager | sed 's/^/ /' || true
+ git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4
+ systemctl restart "$SERVICE" || die "ROLLBACK FAILED: service would not restart" 4
+ printf '[Deploy] waiting for rollback'
+ if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi
+ die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4
+fi
+
+# ── Post-conditions ──────────────────────────────────────────────────────────
+SERVED=$(curl -sf --max-time 5 "${API_URL}/api/health" || echo '')
+[ -n "$SERVED" ] || die "health endpoint not responding after readiness" 3
-echo ""
-echo "[Deploy] Deployment complete! 🎉"
-echo "[Deploy] ${COMMIT_COUNT} commit(s) deployed: ${OLD_HASH:0:8} → ${NEW_HASH:0:8}"
+say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"
+say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
+say "done ✅"
old mode 100644
old mode 100644
new mode 100755
new mode 100755
@@ -1,32 +1,89 @@
@@ -1,32 +1,89 @@
-#!/usr/bin/env sh
-set -eu
+#!/usr/bin/env bash
+# ═══════════════════════════════════════════════════════════════
+# OpenVibe.Live — post-deploy verification
+#
+# Every check reports pass or fail and the script exits non-zero if any REQUIRED check failed.
+# The previous version ended every command with `|| true`, so it always exited 0 — it printed
+# information but could never tell you the deploy was broken.
+#
+# sudo ./post-deploy-check.sh [domain] [service]
+#
+# Exit: 0 all required checks passed · 1 one or more required checks failed
+# ═══════════════════════════════════════════════════════════════
+set -uo pipefail # deliberately NOT -e: we want to run every check, then report.
DOMAIN="${1:-openvibe.live}"
DOMAIN="${1:-openvibe.live}"
-SERVICE="${2:-openvibelive}"
+SERVICE="${2:-openvibe-live}"
+API_URL="${API_URL:-http://127.0.0.1:3000}"
-echo "== systemd =="
-sudo systemctl status "$SERVICE" --no-pager || true
+FAILED=0
+pass() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
+fail() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAILED=$((FAILED+1)); }
+warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
+sect() { printf '\n== %s ==\n' "$1"; }
-echo
- echo "== nginx test =="
-sudo nginx -t
+sect "service"
+if systemctl is-active --quiet "$SERVICE"; then pass "$SERVICE is active"
+else fail "$SERVICE is NOT active"; fi
+if systemctl is-enabled --quiet "$SERVICE" 2>/dev/null; then pass "$SERVICE is enabled at boot"
+else warn "$SERVICE is not enabled at boot"; fi
-echo
- echo "== health check =="
-curl -fsS "https://$DOMAIN/api/health" || true
+sect "socket activation"
+if systemctl list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && systemctl is-active --quiet "${SERVICE}.socket"; then
+ pass "${SERVICE}.socket active — restarts queue connections instead of refusing them"
+else
+ warn "no active socket unit — a restart will refuse connections for the length of boot"
+fi
-echo
- echo "== listening ports =="
-ss -tulpn | grep -E ':(80|443|1935|9935|9710|9711|4443|3000)\b' || true
+sect "readiness"
+# Readiness, not liveness: /api/ready only returns 200 once the database answers and boot is done.
+READY_BODY=$(curl -sf --max-time 10 "${API_URL}/api/ready" 2>/dev/null || echo '')
+if [ -n "$READY_BODY" ]; then pass "local /api/ready -> 200"
+else fail "local /api/ready did not return 200 (process up but not serving)"; fi
-echo
- echo "== ufw =="
-sudo ufw status || true
+sect "public reachability"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "https://$DOMAIN/api/health" || echo 000)
+if [ "$CODE" = "200" ]; then pass "https://$DOMAIN/api/health -> 200"
+else fail "https://$DOMAIN/api/health -> $CODE"; fi
+HOME_CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://$DOMAIN/" || echo 000)
+if [ "$HOME_CODE" = "200" ]; then pass "https://$DOMAIN/ -> 200"
+else fail "https://$DOMAIN/ -> $HOME_CODE"; fi
-echo
- echo "== fail2ban =="
-sudo fail2ban-client status || true
+sect "asset versioning"
+# A versioned asset must exist at the exact URL the freshly-served HTML asks for. If HTML and
+# assets ever disagree the page half-loads, which is worse than being plainly down.
+HTML=$(curl -s --max-time 20 "https://$DOMAIN/" || echo '')
+MISSING=0; CHECKED=0
+while IFS= read -r asset; do
+ [ -n "$asset" ] || continue
+ CHECKED=$((CHECKED+1))
+ AC=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://$DOMAIN$asset" || echo 000)
+ [ "$AC" = "200" ] || { fail "asset referenced by HTML is missing: $asset ($AC)"; MISSING=$((MISSING+1)); }
+done <<< "$(printf '%s' "$HTML" | grep -oE '(src|href)="/(js|css)/[^"]+"' | sed -E 's/^(src|href)="//; s/"$//' | sort -u | head -40)"
+[ "$CHECKED" -gt 0 ] && [ "$MISSING" -eq 0 ] && pass "$CHECKED referenced JS/CSS assets all resolve"
+[ "$CHECKED" -eq 0 ] && warn "could not parse asset references from HTML"
-echo
- echo "== recent service logs =="
-journalctl -u "$SERVICE" -n 50 --no-pager || true
+sect "nginx"
+if sudo nginx -t >/dev/null 2>&1; then pass "nginx config valid"
+else fail "nginx config INVALID"; sudo nginx -t 2>&1 | sed 's/^/ /'; fi
+
+sect "listening ports"
+PORTS=$(ss -tulpn 2>/dev/null | grep -Eo ':(80|443|1935|9935|3000)\b' | sort -u | tr '\n' ' ')
+[ -n "$PORTS" ] && pass "listening on:$PORTS" || warn "could not read listening ports"
+
+sect "firewall / fail2ban (advisory)"
+sudo ufw status 2>/dev/null | head -1 | sed 's/^/ /' || warn "ufw unavailable"
+sudo fail2ban-client status 2>/dev/null | head -2 | sed 's/^/ /' || warn "fail2ban unavailable"
+
+sect "recent errors in the log"
+ERRS=$(journalctl -u "$SERVICE" --since '5 minutes ago' --no-pager 2>/dev/null | grep -icE 'error|unhandled|ECONNREFUSED|TypeError' || true)
+if [ "${ERRS:-0}" -gt 20 ]; then fail "$ERRS error lines in the last 5 minutes"; journalctl -u "$SERVICE" -n 15 --no-pager | sed 's/^/ /'
+elif [ "${ERRS:-0}" -gt 0 ]; then warn "$ERRS error line(s) in the last 5 minutes"
+else pass "no error lines in the last 5 minutes"; fi
+
+printf '\n'
+if [ "$FAILED" -gt 0 ]; then
+ printf '\033[31m%d required check(s) FAILED\033[0m\n' "$FAILED"
+ exit 1
+fi
+printf '\033[32mAll required checks passed\033[0m\n'
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+# Bind the service to the socket unit that owns its listener. Installed as a drop-in so the main
+# unit stays usable on a box that has not been switched over to socket activation.
+[Unit]
+Requires=openvibe-live.socket
+After=openvibe-live.socket
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+# OpenVibe.Live — listening socket, owned by systemd rather than by the process.
+#
+# nginx proxies straight to 127.0.0.1:3000. Without this, every restart closes that listener and
+# nginx gets ECONNREFUSED for the whole boot — which reaches users as a Cloudflare 502. With the
+# socket held here, connections that arrive mid-restart wait in the kernel accept queue and are
+# served as soon as the new process calls accept().
+#
+# What this does NOT do: it does not preserve established connections. Open WebSockets, WHIP
+# sessions, RTMP ingests and WebRTC transports all live in the process and die with it. This buys
+# clean HTTP continuity, nothing more — see deploy/README.md.
+[Unit]
+Description=OpenVibe.Live HTTP socket
+PartOf=openvibe-live.service
+
+[Socket]
+ListenStream=0.0.0.0:3000
+NoDelay=true
+# Node's libuv expects a non-blocking descriptor.
+NonBlocking=true
+# Match the listen backlog the process would have used itself.
+Backlog=511
+
+[Install]
+WantedBy=sockets.target
@@ -49,15 +49,65 @@
@@ -49,15 +49,65 @@
@keyframes tourDot { 0%, 100% { opacity: 0.5; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.15); } }
@keyframes tourDot { 0%, 100% { opacity: 0.5; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.15); } }
.tour-core-wrap { display: grid; place-items: center; position: relative; z-index: 1; }
.tour-core-wrap { display: grid; place-items: center; position: relative; z-index: 1; }
-.tour-core { position: relative; display: grid; justify-items: center; gap: 4px; padding: 18px 20px 16px; border-radius: 20px; text-align: center; width: 100%; max-width: 300px;
- background: radial-gradient(90% 90% at 50% 0%, color-mix(in srgb, var(--accent) 35%, transparent), transparent 70%), color-mix(in srgb, var(--bg-card) 85%, transparent);
- border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent); box-shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 18px 40px rgba(0,0,0,0.35), 0 0 40px color-mix(in srgb, var(--accent) 25%, transparent); }
-.tour-core b { font-size: 1.02rem; font-weight: 900; }
-.tour-core small { font-size: 0.68rem; color: var(--text-secondary); font-weight: 600; line-height: 1.4; }
-.tour-core-logo { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-size: 1.15rem; color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #22d3ee)); box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 45%, transparent); margin-bottom: 4px; }
-.tour-core-ring { position: absolute; left: 50%; top: 50%; width: 120%; height: 120%; border-radius: 26px; border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); transform: translate(-50%, -50%); animation: tourRing 3.2s ease-out infinite; pointer-events: none; }
-.tour-core-ring--2 { animation-delay: 1.6s; }
-@keyframes tourRing { 0% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.7; } 100% { transform: translate(-50%, -50%) scale(1.12); opacity: 0; } }
+/* ── The hub ─────────────────────────────────────────────────────────────────
+ This was a bordered card sitting in the middle of the diagram, so the wires ran *into a box*
+ instead of into OpenVibe. The box is gone: the hub is now the glowing mark itself, with the
+ name and the feature line as free-standing text beneath it, and the wires converge on the
+ glow. The pulse rings are circles centred on the mark rather than rounded rectangles around a
+ panel, so they read as signal radiating from the logo. */
+.tour-core {
+ position: relative; display: grid; justify-items: center; gap: 3px;
+ padding: 4px 14px 2px; text-align: center; width: 100%; max-width: 320px;
+ background: none; border: 0; box-shadow: none;
+}
+/* A soft pool of light behind the mark, in place of the old panel. */
+.tour-core::before {
+ content: ''; position: absolute; left: 50%; top: 6px; width: 190px; height: 190px;
+ transform: translate(-50%, -22%); border-radius: 50%; pointer-events: none; z-index: -1;
+ background: radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent) 0%, color-mix(in srgb, var(--accent) 10%, transparent) 38%, transparent 68%);
+ animation: tourCoreBreathe 5.6s ease-in-out infinite;
+}
+@keyframes tourCoreBreathe { 0%, 100% { opacity: 0.75; transform: translate(-50%, -22%) scale(1); } 50% { opacity: 1; transform: translate(-50%, -22%) scale(1.07); } }
+.tour-core b {
+ font-size: 1.06rem; font-weight: 900; letter-spacing: -0.01em; position: relative;
+ /* A slow sheen across the wordmark — the one place on the diagram that should catch the eye. */
+ background: linear-gradient(100deg, var(--text-primary) 0%, var(--text-primary) 38%, #fff 48%, color-mix(in srgb, var(--accent) 45%, #fff) 56%, var(--text-primary) 66%, var(--text-primary) 100%);
+ background-size: 280% 100%;
+ -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
+ animation: tourCoreSheen 6.5s ease-in-out infinite;
+ text-shadow: 0 0 24px color-mix(in srgb, var(--accent) 30%, transparent);
+}
+@keyframes tourCoreSheen { 0%, 62% { background-position: 130% 50%; } 100% { background-position: -30% 50%; } }
+/* (.tour-core small removed — the chip grid below is the feature list.) */
+/* The mark: a lit disc, not a flat chip. The inner highlight gives it a glass edge. */
+.tour-core-logo {
+ width: 52px; height: 52px; border-radius: 50%; display: grid; place-items: center;
+ font-size: 1.25rem; color: #fff; margin-bottom: 5px; position: relative; z-index: 1;
+ background:
+ radial-gradient(70% 70% at 32% 26%, rgba(255,255,255,0.42), transparent 60%),
+ linear-gradient(140deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #22d3ee));
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, #fff 22%, transparent),
+ 0 0 26px color-mix(in srgb, var(--accent) 60%, transparent),
+ 0 0 60px color-mix(in srgb, var(--accent) 30%, transparent),
+ inset 0 -4px 10px rgba(0,0,0,0.25);
+ animation: tourCoreGlow 3.8s ease-in-out infinite;
+}
+@keyframes tourCoreGlow {
+ 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, #fff 22%, transparent), 0 0 26px color-mix(in srgb, var(--accent) 55%, transparent), 0 0 58px color-mix(in srgb, var(--accent) 26%, transparent), inset 0 -4px 10px rgba(0,0,0,0.25); }
+ 50% { box-shadow: 0 0 0 1px color-mix(in srgb, #fff 34%, transparent), 0 0 34px color-mix(in srgb, var(--accent) 75%, transparent), 0 0 78px color-mix(in srgb, var(--accent) 38%, transparent), inset 0 -4px 10px rgba(0,0,0,0.25); }
+}
+/* Circles radiating from the mark, sized and centred on the logo rather than on the old panel. */
+.tour-core-ring {
+ position: absolute; left: 50%; top: 32px; width: 52px; height: 52px; border-radius: 50%;
+ border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent);
+ transform: translate(-50%, -50%); animation: tourRing 3.4s ease-out infinite; pointer-events: none;
+}
+.tour-core-ring--2 { animation-delay: 1.7s; }
+@keyframes tourRing {
+ 0% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.85; }
+ 100% { transform: translate(-50%, -50%) scale(3.4); opacity: 0; }
+}
.tour-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 20px 0 0; }
.tour-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 20px 0 0; }
.tour-feat { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; color: var(--text-secondary);
.tour-feat { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; color: var(--text-secondary);
@@ -77,8 +127,11 @@
@@ -77,8 +127,11 @@
.tour-node-text small { display: none; }
.tour-node-text small { display: none; }
.tour-core { max-width: 340px; }
.tour-core { max-width: 340px; }
}
}
-@media (prefers-reduced-motion: reduce) { .tour-wire, .tour-node-dot, .tour-core-ring, .tour-feat { animation: none; } .tour-feat { opacity: 1; transform: none; } }
+@media (prefers-reduced-motion: reduce) { .tour-wire, .tour-node-dot, .tour-core-ring, .tour-feat, .tour-core::before, .tour-core b, .tour-core-logo { animation: none; } .tour-feat { opacity: 1; transform: none; } }
.rs-lite .tour::before, .rs-lite .tour-core-ring { display: none; }
.rs-lite .tour::before, .rs-lite .tour-core-ring { display: none; }
+/* Phones keep the glow but not the repainting gradients. */
+.rs-lite .tour-core b { animation: none; -webkit-text-fill-color: var(--text-primary); background: none; }
+.rs-lite .tour-core::before, .rs-lite .tour-core-logo { animation: none; }
.rs-lite .tour-wire { animation: none; }
.rs-lite .tour-wire { animation: none; }
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
@@ -229,65 +229,141 @@
@@ -229,65 +229,141 @@
.star-why-meta i { margin-right: 4px; }
.star-why-meta i { margin-right: 4px; }
/* ── Compact Star card ───────────────────────────────────────────────────────
/* ── Compact Star card ───────────────────────────────────────────────────────
- The section opened as a full-width feature panel: big avatar, kicker, name, chips, a reason,
- a bio in two languages, an AI overview, a last-live note and — when they were on — a whole
- stream card. That is a lot of front page for one streamer. Collapsed it is now just who they
- are and the button to go watch them; everything else is one tap away, and the card animates
- between the two heights rather than jumping. */
-.star-card.is-compact { padding: 14px 16px 16px; gap: 14px; align-items: center; }
-.star-card.is-compact .star-avatar-wrap { width: 68px; height: 68px; }
-.star-card.is-compact .star-avatar { width: 68px; height: 68px; font-size: 1.5rem; }
-.star-card.is-compact .star-kicker { font-size: 0.6rem; margin-bottom: 2px; }
-.star-card.is-compact .star-name { font-size: clamp(1.05rem, 3.4vw, 1.35rem); margin: 0 0 6px; }
-.star-card.is-compact .star-chips { margin-bottom: 10px; }
+ The first compact pass just shrank the feature layout, and it showed: a centred kicker and
+ name over a bottom-aligned avatar, chips floating off to one side, and three buttons wrapping
+ into a two-plus-one arrangement. Nothing lined up with anything.
+
+ Rebuilt as one deliberate shape — an avatar column and a left-aligned text column, with a
+ single action row underneath both. Everything shares one left edge, the avatar is optically
+ centred against the text block, and the three actions sit on one line at one height. */
+.star-card.is-compact {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ grid-template-areas: "avatar text" "actions actions";
+ align-items: center;
+ gap: 10px 14px;
+ padding: 15px 17px 16px;
+ text-align: left;
+}
+.star-card.is-compact .star-avatar-wrap { grid-area: avatar; width: 62px; height: 62px; align-self: center; }
+.star-card.is-compact .star-avatar { width: 62px; height: 62px; font-size: 1.45rem; }
+.star-card.is-compact .star-body {
+ grid-area: text; display: flex; flex-direction: column; align-items: flex-start;
+ gap: 3px; min-width: 0;
+}
+/* The badge under the avatar duplicates the kicker and was the piece sticking out at an angle. */
+.star-card.is-compact .star-badge,
.star-card.is-compact .star-petals { display: none; }
.star-card.is-compact .star-petals { display: none; }
-.star-card.is-compact .star-actions { margin-top: 0; }
-.star-card.is-compact .star-btn-watch { font-size: 0.86rem; padding: 8px 15px; }
+.star-card.is-compact .star-kicker { font-size: 0.6rem; letter-spacing: 0.15em; margin: 0; }
+.star-card.is-compact .star-name { font-size: clamp(1.1rem, 4.2vw, 1.5rem); margin: 0; }
+.star-card.is-compact .star-name .star-jp { font-size: 0.5em; margin-top: 0; }
+
+/* Category and followers read as one meta line, not as competing pills. */
+.star-card.is-compact .star-chips { gap: 5px; margin: 3px 0 0; }
+.star-card.is-compact .star-chip {
+ font-size: 0.68rem; padding: 2px 9px; border-radius: 999px;
+ background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.12); color: var(--text-secondary);
+}
+.star-card.is-compact .star-chip.gold { color: #fde68a; background: rgba(251,191,36,0.1); border-color: rgba(251,191,36,0.3); }
+
+/* One action row, one height, one line. */
+.star-card.is-compact .star-actions {
+ grid-area: actions; display: flex; flex-wrap: wrap; align-items: center;
+ gap: 8px; margin: 4px 0 0;
+}
+.star-card.is-compact .star-actions .btn,
+.star-card.is-compact .star-expand {
+ height: 38px; padding: 0 15px; font-size: 0.82rem; border-radius: 11px;
+ display: inline-flex; align-items: center; gap: 8px; line-height: 1;
+}
+.star-card.is-compact .star-btn-watch { flex: 1 1 auto; min-width: 140px; justify-content: center; }
+.star-card.is-compact .star-actions .btn-outline { flex: 0 1 auto; }
+.star-card.is-compact .star-expand { flex: 0 0 auto; margin-left: auto; padding-left: 8px; }
/* The disclosure. Fades up as the card grows — see animateHeightChange().
/* The disclosure. Fades up as the card grows — see animateHeightChange().
[hidden] is a UA rule, so any author `display` here silently beats it and the panel never
[hidden] is a UA rule, so any author `display` here silently beats it and the panel never
actually hides. Stated explicitly, the same trap the hero strip fell into. */
actually hides. Stated explicitly, the same trap the hero strip fell into. */
.star-more[hidden] { display: none !important; }
.star-more[hidden] { display: none !important; }
-.star-more { display: block; }
+.star-more { display: block; grid-column: 1 / -1; }
+/* Expanded (non-compact) card: both blocks run the full width under the avatar and text. */
+.star-card > .star-actions, .star-card > .star-more { grid-column: 1 / -1; }
.star-more.is-entering { animation: starMoreIn 0.42s cubic-bezier(.2, 1, .3, 1); }
.star-more.is-entering { animation: starMoreIn 0.42s cubic-bezier(.2, 1, .3, 1); }
@keyframes starMoreIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
@keyframes starMoreIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }
.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }
+/* The avatar ring: a slow conic sweep rather than a static rainbow, so the card has one moving
+ element instead of several competing ones. Paused off-screen by the viewport gate. */
+.star-card.is-compact .star-avatar-ring {
+ inset: -3px; border-radius: 50%;
+ background: conic-gradient(from 0deg, #fbbf24, #f472b6, var(--accent), #fbbf24);
+ animation: starRingSpin 6s linear infinite;
+ opacity: 0.9;
+}
+@keyframes starRingSpin { to { transform: rotate(360deg); } }
+.star-card.is-compact:hover .star-avatar-ring { opacity: 1; }
+.star-card.is-compact .star-avatar { transition: transform 0.28s cubic-bezier(.2, 1.2, .3, 1); }
+.star-card.is-compact:hover .star-avatar { transform: scale(1.04); }
+
.star-expand {
.star-expand {
- display: inline-flex; align-items: center; gap: 8px;
- padding: 7px 14px 7px 8px; border-radius: 999px; cursor: pointer; font: inherit;
- font-size: 0.78rem; font-weight: 750;
- color: color-mix(in srgb, #fbbf24 80%, var(--text-primary));
- background: color-mix(in srgb, #fbbf24 10%, transparent);
- border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent);
+ cursor: pointer; font: inherit; font-weight: 750;
+ color: color-mix(in srgb, #fbbf24 82%, var(--text-primary));
+ background: color-mix(in srgb, #fbbf24 9%, transparent);
+ border: 1px solid color-mix(in srgb, #fbbf24 26%, transparent);
transition: transform 0.2s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;
transition: transform 0.2s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;
}
}
.star-expand:hover {
.star-expand:hover {
transform: translateY(-1px);
transform: translateY(-1px);
- background: color-mix(in srgb, #fbbf24 18%, transparent);
- border-color: color-mix(in srgb, #fbbf24 55%, transparent);
- box-shadow: 0 6px 18px color-mix(in srgb, #fbbf24 22%, transparent);
+ background: color-mix(in srgb, #fbbf24 17%, transparent);
+ border-color: color-mix(in srgb, #fbbf24 50%, transparent);
+ box-shadow: 0 6px 16px color-mix(in srgb, #fbbf24 20%, transparent);
}
}
.star-expand:active { transform: translateY(0) scale(0.985); }
.star-expand:active { transform: translateY(0) scale(0.985); }
.star-expand:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; }
.star-expand:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; }
.star-expand i {
.star-expand i {
- width: 20px; height: 20px; flex: none; border-radius: 50%; display: grid; place-items: center;
- font-size: 0.58rem; color: #1a1506; line-height: 1;
+ width: 19px; height: 19px; flex: none; border-radius: 50%; display: grid; place-items: center;
+ font-size: 0.55rem; color: #1a1506; line-height: 1;
background: linear-gradient(140deg, #fbbf24, #f59e0b);
background: linear-gradient(140deg, #fbbf24, #f59e0b);
transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);
transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);
}
}
.star-expand.is-open i { transform: rotate(180deg); }
.star-expand.is-open i { transform: rotate(180deg); }
-.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 44vw; }
+.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* The gold border sweep is the card's one loud effect; calm it down when compact so it frames
+ the card instead of shouting over it. */
+.star-card.is-compact ~ .star-card-border::before,
+.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }
-@media (max-width: 640px) {
- .star-card.is-compact { padding: 12px 13px 14px; gap: 11px; grid-template-columns: auto 1fr; }
- .star-card.is-compact .star-avatar-wrap, .star-card.is-compact .star-avatar { width: 54px; height: 54px; }
+/* Wide enough for one line: avatar, who they are, and the actions on the right. Below this the
+ actions drop to their own row rather than squeezing. */
+@media (min-width: 820px) {
+ .star-card.is-compact {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ grid-template-areas: "avatar text actions";
+ gap: 16px;
+ }
+ .star-card.is-compact .star-actions { margin: 0; justify-content: flex-end; flex-wrap: nowrap; }
+ .star-card.is-compact .star-btn-watch { flex: 0 0 auto; min-width: 0; }
+ .star-card.is-compact .star-expand { margin-left: 0; }
+ .star-card.is-compact .star-more { grid-column: 1 / -1; }
+}
+
+@media (max-width: 560px) {
+ .star-card.is-compact { padding: 13px 13px 14px; gap: 9px 11px; }
+ .star-card.is-compact .star-avatar-wrap,
+ .star-card.is-compact .star-avatar { width: 52px; height: 52px; }
.star-card.is-compact .star-avatar { font-size: 1.2rem; }
.star-card.is-compact .star-avatar { font-size: 1.2rem; }
- .star-card.is-compact .star-actions { flex-wrap: wrap; gap: 7px; }
- .star-expand { font-size: 0.72rem; padding: 6px 11px 6px 6px; }
+ .star-card.is-compact .star-actions .btn,
+ .star-card.is-compact .star-expand { height: 36px; font-size: 0.78rem; padding: 0 12px; }
+ /* On a phone the three actions stack two-then-one cleanly rather than at random. */
+ .star-card.is-compact .star-btn-watch { flex: 1 1 100%; }
+ .star-card.is-compact .star-expand { margin-left: 0; flex: 1 1 auto; justify-content: center; }
+}
+@media (max-width: 380px) {
+ .star-card.is-compact .star-actions .btn-outline { flex: 1 1 100%; justify-content: center; }
}
}
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.star-more.is-entering { animation: none; }
.star-more.is-entering { animation: none; }
.star-card.is-swapping { transition: none; }
.star-card.is-swapping { transition: none; }
- .star-expand, .star-expand i { transition: none; }
+ .star-expand, .star-expand i, .star-card.is-compact .star-avatar { transition: none; }
+ .star-card.is-compact .star-avatar-ring { animation: none; }
}
}
@@ -90,8 +90,8 @@
@@ -90,8 +90,8 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="/css/style.css?v=194">
<link rel="stylesheet" href="/css/style.css?v=194">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
- <link rel="stylesheet" href="/css/home-tour.css?v=7">
- <link rel="stylesheet" href="/css/i18n-star.css?v=7">
+ <link rel="stylesheet" href="/css/home-tour.css?v=8">
+ <link rel="stylesheet" href="/css/i18n-star.css?v=10">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
@@ -397,11 +397,10 @@
@@ -397,11 +397,10 @@
</div>
</div>
<div class="home-cta-inner">
<div class="home-cta-inner">
- <div class="cta-eyebrow">
- <span class="cta-live-dot"></span>
- HOME-GROWN & HOBBYIST-RUN — NOT ANOTHER CORPORATE STREAM SITE
- </div>
-
+ <!-- The all-caps eyebrow here ("HOME-GROWN & HOBBYIST-RUN — NOT ANOTHER CORPORATE
+ STREAM SITE") was a louder restatement of the lede two lines below it, which
+ already says the platform is built by one person who streams here. One of them
+ is enough, and the lede is the one that reads like a human wrote it. -->
<h2 class="cta-hero">Streaming the way it <span class="cta-hero-grad">should</span> be.</h2>
<h2 class="cta-hero">Streaming the way it <span class="cta-hero-grad">should</span> be.</h2>
<p class="cta-lede">
<p class="cta-lede">
OpenVibe.Live is a living platform built by <strong>one person who actually streams here</strong>, hangs out in chat, and ships your ideas. No boardroom, no algorithm games, no mystery bans — just an <strong>epic</strong> streaming stack packed with things the big sites flat-out don't have.
OpenVibe.Live is a living platform built by <strong>one person who actually streams here</strong>, hangs out in chat, and ships your ideas. No boardroom, no algorithm games, no mystery bans — just an <strong>epic</strong> streaming stack packed with things the big sites flat-out don't have.
@@ -5131,7 +5130,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5131,7 +5130,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
-<script src="/js/app.js?v=166" defer></script>
+<script src="/js/app.js?v=167" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -5215,7 +5214,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5215,7 +5214,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/shared/footer.js?v=1" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/home-fx.js?v=9" defer></script>
<script src="/js/home-fx.js?v=9" defer></script>
-<script src="/js/home-tour.js?v=6" defer></script>
+<script src="/js/home-tour.js?v=8" defer></script>
<script src="/js/ov-guide.js?v=2" defer></script>
<script src="/js/ov-guide.js?v=2" defer></script>
<script src="/js/guides-live.js?v=6" defer></script>
<script src="/js/guides-live.js?v=6" defer></script>
@@ -9647,19 +9647,19 @@ async function loadHomeStar() {
@@ -9647,19 +9647,19 @@ async function loadHomeStar() {
<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>
<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>
<h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
<h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
<div class="star-chips">${chips}</div>
<div class="star-chips">${chips}</div>
- <div class="star-more" id="star-more" hidden>
- ${why}
- ${bioHtml}
- ${overview}
- ${offline}
- </div>
- <div class="star-actions">
+ </div>
+ <div class="star-more" id="star-more" hidden>
+ ${why}
+ ${bioHtml}
+ ${overview}
+ ${offline}
+ </div>
+ <div class="star-actions">
<a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a>
<a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a>
<a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>
<a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>
<button type="button" class="star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more">
<button type="button" class="star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more">
<i class="fa-solid fa-chevron-down"></i><span>More about ${name}</span>
<i class="fa-solid fa-chevron-down"></i><span>More about ${name}</span>
</button>
</button>
- </div>
</div>
</div>
</div>
</div>
</div>`;
</div>`;
@@ -37,7 +37,8 @@
@@ -37,7 +37,8 @@
el.innerHTML = `
el.innerHTML = `
<section class="tour" aria-label="Stream from OpenVibe.Live and restream everywhere">
<section class="tour" aria-label="Stream from OpenVibe.Live and restream everywhere">
<div class="tour-head">
<div class="tour-head">
- <div class="tour-kicker"><i class="fa-solid fa-satellite-dish"></i> Restream</div>
+ <!-- The "RESTREAM" eyebrow that used to sit here restated the heading directly
+ below it and the diagram directly below that. Three statements of one idea. -->
<h2>One stream. Everywhere.</h2>
<h2>One stream. Everywhere.</h2>
<p>Go live here once and mirror it to every platform at the same time — your chat, emotes, sound commands, robot controls, VODs and clips stay in one place.</p>
<p>Go live here once and mirror it to every platform at the same time — your chat, emotes, sound commands, robot controls, VODs and clips stay in one place.</p>
</div>
</div>
@@ -52,7 +53,9 @@
@@ -52,7 +53,9 @@
<span class="tour-core-ring"></span><span class="tour-core-ring tour-core-ring--2"></span>
<span class="tour-core-ring"></span><span class="tour-core-ring tour-core-ring--2"></span>
<span class="tour-core-logo"><span class="ov-mark" data-size="30" style="color:#fff"></span></span>
<span class="tour-core-logo"><span class="ov-mark" data-size="30" style="color:#fff"></span></span>
<b>OpenVibe.Live</b>
<b>OpenVibe.Live</b>
- <small><1s latency · chat · emotes · sounds · controls · VOD · clips</small>
+ <!-- The old feature line here ("<1s latency · chat · emotes · …") listed the same
+ things the chip grid below spells out properly. Two statements of the same
+ list, one of them abbreviated, is worse than one. The hub just names itself. -->
</div>
</div>
</div>
</div>
<div class="tour-col tour-col--dst">
<div class="tour-col tour-col--dst">
@@ -673,30 +673,89 @@ app.get('/api/health', (req, res) => {
@@ -673,30 +673,89 @@ app.get('/api/health', (req, res) => {
});
});
});
});
+/**
+ * GET /api/ready — readiness, which is not the same thing as liveness.
+ *
+ * /api/health answers "is a process listening". That is true from the moment the socket is
+ * accepted, which during a deploy is *before* the database is open and before the request paths
+ * this server actually serves will work. A deploy script that waits on /api/health therefore
+ * declares success while the site is still returning errors.
+ *
+ * This answers "can this process serve a real request": the database responds, the schema is
+ * initialised, and boot has completed. 503 until all of that is true, so a health gate can be a
+ * genuine gate.
+ */
+let _bootComplete = false;
+app.get('/api/ready', (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ const checks = { boot: _bootComplete, db: false };
+ try {
+ // Cheapest possible proof that the connection is open and the schema exists.
+ db.get('SELECT 1 AS ok');
+ checks.db = true;
+ } catch (err) {
+ checks.dbError = String(err && err.message || err).slice(0, 200);
+ }
+ const ready = checks.boot && checks.db;
+ res.status(ready ? 200 : 503).json({
+ ready,
+ checks,
+ uptime: Math.round(process.uptime()),
+ // Advisory only — a deploy gate should not wait on optional subsystems, which can be
+ // legitimately unavailable (no mediasoup worker on a box that only relays RTMP, say).
+ optional: {
+ sfu: (() => { try { return !!webrtcSFU.isReady?.(); } catch { return null; } })(),
+ media: (() => { try { return require('./media-client').lastOk === true; } catch { return null; } })(),
+ },
+ });
+});
+
// ── Updates / Changelog ──────────────────────────────────────
// ── Updates / Changelog ──────────────────────────────────────
-const { execSync } = require('child_process');
+const { execFile } = require('child_process');
const REPO_DIR = path.resolve(__dirname, '..');
const REPO_DIR = path.resolve(__dirname, '..');
/**
/**
- * GET /api/updates — returns recent git commit history for the updates page.
- * Query params: ?limit=30 (default 30, max 100)
+ * GET /api/updates — recent commit history for the updates page.
+ *
+ * This used to shell out with execSync on the request path. execSync blocks the entire event
+ * loop: for however long git takes — up to its 5s timeout — no other request on the box is
+ * served, no WebSocket frame is read, and no chat message is delivered. The home page fetches
+ * this on every load, so a cold git call after a deploy stalled everyone at once.
+ *
+ * Now: one async read, memoised, shared by concurrent callers. The history only changes when we
+ * deploy, so a minute of staleness is free.
*/
*/
-app.get('/api/updates', (req, res) => {
- try {
- const limit = Math.min(Math.max(parseInt(req.query.limit) || 30, 1), 100);
- const raw = execSync(
- `git --no-pager log --pretty=format:'%H||%h||%s||%an||%aI' -${limit}`,
- { cwd: REPO_DIR, encoding: 'utf8', timeout: 5000 }
- );
- const commits = raw.trim().split('\n').filter(Boolean).map(line => {
- const [hash, short, subject, author, date] = line.split('||');
- return { hash, short, subject, author, date };
- });
- res.json({ commits });
- } catch (err) {
- console.error('[Updates] git log error:', err.message);
- res.status(500).json({ error: 'Failed to read update history' });
+const UPDATES_TTL_MS = 60_000;
+let _updatesCache = { at: 0, commits: null, inflight: null };
+function readCommits(limit) {
+ if (_updatesCache.commits && Date.now() - _updatesCache.at < UPDATES_TTL_MS) {
+ return Promise.resolve(_updatesCache.commits);
}
}
+ // Coalesce: a burst of first-loads after a deploy must not spawn a git process each.
+ if (_updatesCache.inflight) return _updatesCache.inflight;
+ _updatesCache.inflight = new Promise((resolve) => {
+ execFile('git', ['--no-pager', 'log', '--pretty=format:%H||%h||%s||%an||%aI', `-${limit}`],
+ { cwd: REPO_DIR, encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024 },
+ (err, stdout) => {
+ _updatesCache.inflight = null;
+ if (err) return resolve(_updatesCache.commits || null);
+ const commits = String(stdout).trim().split('\n').filter(Boolean).map(line => {
+ const [hash, short, subject, author, date] = line.split('||');
+ return { hash, short, subject, author, date };
+ });
+ _updatesCache = { at: Date.now(), commits, inflight: null };
+ resolve(commits);
+ });
+ });
+ return _updatesCache.inflight;
+}
+app.get('/api/updates', async (req, res) => {
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 30, 1), 100);
+ // Always read the widest window we serve, so one cache entry answers every limit.
+ const commits = await readCommits(100);
+ if (!commits) return res.status(503).json({ error: 'Update history unavailable' });
+ res.set('Cache-Control', 'public, max-age=60');
+ res.json({ commits: commits.slice(0, limit) });
});
});
/**
/**
@@ -1145,6 +1204,9 @@ async function start() {
@@ -1145,6 +1204,9 @@ async function start() {
console.warn('[Server] WARNING: Mediasoup announcedIp is configured as a local address. External WebRTC clients may be unable to connect. Set MEDIASOUP_ANNOUNCED_IP to your public WHIP/WebRTC hostname.');
console.warn('[Server] WARNING: Mediasoup announcedIp is configured as a local address. External WebRTC clients may be unable to connect. Set MEDIASOUP_ANNOUNCED_IP to your public WHIP/WebRTC hostname.');
}
}
console.log('');
console.log('');
+ // Flip readiness only once everything the request paths depend on is up. /api/ready
+ // returns 503 before this line, which is what makes a deploy health gate meaningful.
+ _bootComplete = true;
console.log('[Server] Ready. Good vibes only. ▶');
console.log('[Server] Ready. Good vibes only. ▶');
console.log('');
console.log('');
@@ -1469,6 +1531,10 @@ function shutdown() {
@@ -1469,6 +1531,10 @@ function shutdown() {
// go-live until viewers refresh. Done up-front so RS has the whole shutdown window to propagate.
// go-live until viewers refresh. Done up-front so RS has the whole shutdown window to propagate.
try { const n = require('./integrations/rs-passthrough-relay').stopAll(); if (n) console.log(`[Server] Closed ${n} RobotStreamer passthrough(s)`); } catch { /* */ }
try { const n = require('./integrations/rs-passthrough-relay').stopAll(); if (n) console.log(`[Server] Closed ${n} RobotStreamer passthrough(s)`); } catch { /* */ }
+ // Stop advertising readiness immediately: from here on this process is draining, and anything
+ // gating on /api/ready should see that before the socket actually closes.
+ _bootComplete = false;
+
// Small delay to let the message reach clients before closing sockets
// Small delay to let the message reach clients before closing sockets
setTimeout(() => {
setTimeout(() => {
restreamManager.stopViewerCountPolling();
restreamManager.stopViewerCountPolling();