openvibers/openvibe.live Commits

Deployment: real readiness gating, rollback, and the socket unit under version control

OP OpenVibers committed 16 Sept 2026 · b3c0eb6b62 · 10 files, +551 −215

The deploy script could not have worked as written. Its defaults pointed at /opt/openvibelive and
a service named openvibelive, while the unit file, AGENTS.md and the actual installation all use
/opt/openvibe.live and openvibe-live — so the unit-install step compared against a path that does
not exist and silently did nothing on every run. It also restarted the service when there was
nothing to deploy (a free outage), and its wait loop could expire and still print "Deployment
complete" because nothing checked whether the loop had actually succeeded.

Rewritten around one idea: prove the new process can serve a request before calling it a success.

  - Correct paths and service name; units, socket and drop-in all installed from deploy/systemd.
  - flock, so two deploys cannot interleave and leave the tree on one commit and the process on
    another.
  - Everything that can fail now fails BEFORE the service is interrupted: fetch, fast-forward,
    node --check on every changed JS file, and npm ci only when the lockfile actually moved
    (it rebuilds native modules, so it must not run every time).
  - No restart when there is nothing new, unless --restart says so.
  - Readiness gate with a real timeout and a real failure path: on timeout it prints the journal,
    resets to the previous commit, restarts, and re-verifies — exiting 3 if it rolled back
    successfully and 4 if the rollback itself failed.
  - DRY_RUN=1 prints the plan without touching anything.

New /api/ready, which is what the gate waits on. /api/health answers "a process is listening",
true from the moment the socket is accepted and long before the database is open — which is
exactly why a health-gated deploy declares success while the site is still erroring. /api/ready
answers "this process can serve a real request": boot finished and the database responds. It also
goes 503 the instant shutdown begins, so a draining process stops advertising itself.

post-deploy-check.sh ended every command with `|| true` and therefore always exited 0. It now
reports pass/fail per check and exits non-zero if a required one failed, and it verifies that
every JS/CSS asset the freshly-served HTML references actually resolves — a half-loaded page is
worse than an obviously down one.

The socket unit and its drop-in are now in deploy/systemd/ rather than existing only on the
server, where nothing could recreate them. Documented plainly: the socket keeps HTTP connections
queued across a restart, and does not preserve established WebSocket, WHIP, RTMP or WebRTC
sessions, which live in the process and die with it.

Also: /api/updates shelled out with execSync on the request path, blocking the entire event loop
for up to five seconds — no other request served, no WebSocket frame read — and the home page
calls it on every load. It is one async read now, memoised for a minute and coalesced so a burst
of first-loads after a deploy cannot spawn a git process each.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex 53f29fcb..493a5a07 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -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 pipefail
FORCE_DEPLOY=false
++ALWAYS_RESTART=false
while [ "$#" -gt 0 ]; do
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
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}"
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 0
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
-- 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" 2
else
-- 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 ✅"
ddiff --git a/deploy/scripts/post-deploy-check.sh b/deploy/scripts/post-deploy-check.sh
oold mode 100644
nnew mode 100755
iindex 7efc734c..8c118c8e
---- a/deploy/scripts/post-deploy-check.sh
++++ b/deploy/scripts/post-deploy-check.sh
@@@ -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'
ddiff --git a/deploy/systemd/openvibe-live.service.d/socket.conf b/deploy/systemd/openvibe-live.service.d/socket.conf
nnew file mode 100644
iindex 00000000..661b02c9
---- /dev/null
++++ b/deploy/systemd/openvibe-live.service.d/socket.conf
@@@ -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
ddiff --git a/deploy/systemd/openvibe-live.socket b/deploy/systemd/openvibe-live.socket
nnew file mode 100644
iindex 00000000..13643112
---- /dev/null
++++ b/deploy/systemd/openvibe-live.socket
@@@ -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
ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex 39cabc53..10d4d9b6 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -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; }
/* ═══════════════════════════════════════════════════════════════
ddiff --git a/public/css/i18n-star.css b/public/css/i18n-star.css
iindex 0b520dc6..ceb690b6 100644
---- a/public/css/i18n-star.css
++++ b/public/css/i18n-star.css
@@@ -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 never
actually 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; }
}
ddiff --git a/public/index.html b/public/index.html
iindex 635a5d34..25d036de 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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 &amp; 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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex c9434d28..91613000 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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>`;
ddiff --git a/public/js/home-tour.js b/public/js/home-tour.js
iindex 4c7c0660..bcd00288 100644
---- a/public/js/home-tour.js
++++ b/public/js/home-tour.js
@@@ -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>&lt;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">
ddiff --git a/server/index.js b/server/index.js
iindex 2473fb95..7fa3745d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 sockets
setTimeout(() => {
restreamManager.stopViewerCountPolling();