System Architecture#
Overview#
OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe services:
- Express.js (CommonJS) — HTTP API, HTML documents, static assets
- PostgreSQL (
openvibe-sdk/db, through PgBouncer; PGlite in development and tests) — users, channels, streams, tokens, AI state, request analytics (ov_live, since 2026-10-08) - WebSocket (
ws) — broadcast signaling (chat and calls are OpenVibe.Chat's; robots are OpenVibe.Bot's) - mediasoup — WebRTC SFU; werift — WHIP ingest; Node-Media-Server — RTMP ingest
- FFmpeg — recording hand-off, restreams, thumbnails, audio capture for transcription
- OpenVibe.Network — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (
openvibe-shared, a pinned OpenVibe.Shared release served at/shared/*fromnode_modules) - OpenVibe.Media — VODs, clips, pastes, thumbnails and files. Live proxies to it
(server/media-client.js,server/media-proxy/); the localvods/clips/pastestables are frozen: nothing reads or
writes them (vods-and-clips.md), and they are dropped by
this procedure. - OpenVibe.Community — pastes (
server/pastes-client.js) and VOD/clip comments:/api/commentsis an adapter over Community comment threads
(server/comments-client.js, vods-and-clips.md); the localcommentstable is frozen, read-only.
Authentication#
- Network-issued RS256 JWTs, verified with the Network public key (
server/auth/auth.js). - API tokens (
hbt_…) stored as SHA-256 hashes; scopes are enforced — reads need any scope,
writes need the area's scope, and money/staff/credential routes refuse tokens. - WebSockets start anonymous or authenticated;
joincan upgrade an anonymous connection, and a
change of identity rebuilds the socket (openvibe-auth-changedinpublic/js/chat.js). - The OpenVibe account is the identity.
network.user.updatedatPOST /internal/network-events
keepssubject_projectionand the linked account's username, display name, picture, colour and
role (server/auth/subject-projection.js;linked_accountsmaps a Live user to its subject).users.roleis that projection's output (Network's staff roles, plus Live's ownstreamer) and is
read locally. The key-onlyPOST /internal/user-rolepush is gone. - Live keeps no emails or passwords: nothing writes or reads
users.emailorusers.password_hash
(test/identity-columns.test.js). The contract step cleared the legacy values before the switch to
PostgreSQL: no row holds an email, andpassword_hashholds only the SSO and anonymous markers (checked
onov_liveon 2026-10-08); its SQLite script went with the SQLite code. The columns themselves stay.
Frontend loading#
The SPA is plain JavaScript with no build step. What loads is decided per route:
public/index.html shell: navbar, home page, empty <section id="page-*"> shells, core scripts
public/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetch
routes: URL pattern → features
public/fragments/*.html page markup for channel, VOD/clip players, dashboard, broadcast, chat, documentation, content, moments
public/css/features/*.css rules used only by one feature (split out of style.css / broadcast.css / i18n-star.css)
public/js/ov-loader.js configures /shared/web-runtime.js (openvibe-shared): ov.load(feature), ov.route(path), ov.prefetch(),
route generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks()
public/js/app.js core: router, auth, API helper, shared renderers, connection pill
public/js/app-*.js route code split out of app.js: home, channel, media, chatpage, docs
- First paint.
server/web/assets.jsrendersindex.htmlfor the requested path: the route's
stylesheets go in<head>(right afterstyle.css), its scripts after the core scripts, and its
fragment is inlined into the section shell. A direct visit needs no extra round trip. - Navigation.
routeFromURL()callswhenRouteReady(page, render): the loader inserts the
feature's markup first (modules bind to it on load), then dependencies, stylesheets and scripts in
order, runs the feature'safterhook once, and renders only if the route generation is still
current (a slow bundle can no longer start the broadcast desk on the page you moved to). - Unknown pages. Every page path gets the same shell, but a path that names nothing answers
404 (server/web/page-status.js, the SPA fallback): an unknown route, a channel whose user does
not exist, a VOD or clip Media does not have or that this visitor may not see (private items, by
the rule in server/media-proxy/access.js), an unknown paste, recap or stream. The client renders
its not-found view (showNotFound()) at the same URL. The server's page list mirrorsrouteFromURL(): add a new top-level route to both (test/page-status.test.js compares them).
Media and paste lookups are cached per id for a minute and wait at most 2.5 s; when Media or
Community cannot answer, the page is 200, never a false 404. - Stubs. Inline handlers such as
onclick="openSetupHub()"work before a feature is loaded: the
stub loads the feature, then calls the real function. - Prefetch. Hover or keyboard focus on an in-site link prefetches that route's files
(<link rel=prefetch>, download only); a route'sidlelist is prefetched after load. Skipped on
Save-Data, 2G and low-memory devices. - Lifecycle.
teardownRoute()starts a new generation and disposesov.scope()(timers,
listeners, observers, AbortSignal), and stops the players, sockets and polls each page owns.test/browser/smoke.jsnavigates a nine-route lap three times and fails if timers, sockets,
window/document listeners or DOM nodes keep growing. - Page events.
showPage()dispatchesov:page; the loader dispatchesov:fragmentwhen markup
arrives. Modules listen to these instead of observing every section. - Assets. Every asset URL is content-hashed at serve time (see deploy.md).
- Cached-first rendering.
apiSWR()(public/js/app-home.js) paints the home page's public data
fromlocalStorage(versioned records, keyed by user id) and reconciles with the fresh response. - Featured live stream.
public/js/home-featured.js(featurefeatured) loads only when someone is
live:GET /api/home/featuredpicks and rotates the stream; playback is FLV (RTMP) or the JSMPEG relay,
live frames for WebRTC. Off switch remembered inlocalStorage(ov_home_featured_off). - Content and Moments.
/contentis what people made (VODs, clips people cut, people's pastes);/momentsis what the AI made (auto-clips, AI moment pastes, AI-written after-show recaps), every
card marked AI./vods,/clipsand/pastesare/contentwith that filter (the address becomes/content?type=…). One lazy feature (public/js/content-feed.js, fragmentscontent/moments):
a masonry of cards (one column on phones, two or three wider), infinite scroll by cursor, clips
that play muted one at a time when mostly in view. The server merges the sources
(server/content/feed.js,GET /api/content/feedand/api/content/moments): who made an item is
the store's answer, never a guess here (Media's clipauto_generated, Community's pasteorigin,stream_recaps.ai), each source filters server-side, public items only. The cursor carries each
source's offset (and a Top window's start); source pages are cached 30 s (Top 2 min) and a page
waits at most 4 s for Media and Community, leaving a slow one out and asking again on the next
page. Auto-clips made before Media kept the flag are marked from Live'sauto_clip_log
(syncAutoClipFlagsinserver/ai/auto-clip-job.js). - Voice channels.
public/js/call.js+voice-channels.js(featurevoice, chat route): full-mesh
WebRTC, signalling over/ws/call. Calls are OpenVibe.Chat's (itsserver/calls/): nginx sends/ws/call,/api/streams/voice-channels…and/api/streams/:id/callto Chat (the include indeploy/nginx/openvibe.live.conf),
and Live's own call server was deleted on 2026-10-10. The newcomer offers, existing members answer; Chat pushes the
channel list to every chat socket (voice-channelsmessage); TURN credentials come fromserver/net/turn.js
(short-lived withTURN_AUTH_SECRET). Live tells Chat about a stream's call throughserver/streaming/calls-authority.js: go-live, stream end, WHIP teardown and the admin force-end create or remove the
stream's voice channel (POST/DELETE/internal/calls/stream-channel; OpenVibe.Chatdocs/calls-cutover.md). - Budgets.
scripts/perf/check-budgets.js(innpm test) fails if the home page's HTML, eager
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
Streaming Protocols#
WebRTC (browser broadcast)#
Browser → mediasoup Router → Consumers → Viewers (viewer sockets only for live streams)
WHIP#
OBS / browser → POST /whip/:slot (slot key) → werift → mediasoup → Viewers
RTMP#
OBS → Node-Media-Server :1935 → HTTP-FLV (127.0.0.1 only) → Viewers via /api/streams/rtmp-proxy
JSMPEG#
FFmpeg → WebSocket relay → Canvas viewers
Recording, VODs and clips are produced by OpenVibe.Media from the live session (server/streaming/recorder.js starts and stops Media recordings).
Chat#
Client WS → OpenVibe.Chat (127.0.0.1:4400; nginx routes /ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds there)
Live runs no chat WebSocket server or chat REST routes: it delivers to Chat's typed ingress through server/chat/chat-delivery.js (producer pushes, moderation, cache hints) and reads Chat's snapshot for presence. The room is derived from the stream the client joined; offline channel chat applies the channel's bans and chat rules. Reconnects are jittered; a server_restart notice shows "OpenVibe is updating" and refills history on reconnect. See chat-system.md.
Server runtime#
| Module | Path | Purpose |
|---|---|---|
| Entry | server/index.js | Express, security headers + report-only CSP, static mounts, WS upgrade, boot, shutdown |
| Assets | server/web/assets.js | content hashes, HTML rewriting, route assets, fragments, previous-release serving |
| Serializers | server/web/serializers.js | public shapes for streams, slots, channels, profiles (secrets off by default) |
| Egress | server/net/egress.js | fetching user-chosen URLs: address policy, redirects, loopback proxy for yt-dlp/ffmpeg |
| Jobs | server/utils/jobs.js | single-flight, jittered background loops; stopped on shutdown; stats for diagnostics |
| Limits | server/utils/limit.js | semaphores for CPU-heavy work (offline encodes, stream-memory captures) |
| Diagnostics | server/diagnostics.js | event-loop delay, memory, sockets, jobs, queues, migrations → GET /api/admin/diagnostics |
| Log redaction | server/utils/redact.js | stream keys and credentials in log lines |
| Database | server/db/database.js | every query, async (openvibe-sdk/db) |
| Migrations | migrations/NNNN_*.sql | the schema, applied at boot by the owner role (openvibe-sdk/db migrate: ledger, advisory lock, expand/migrate/contract phases) |
| Chat delivery | server/chat/chat-delivery.js | Live's one seam to OpenVibe.Chat (pushes, moderation, cache hints, presence) |
| Streams | server/streaming/routes.js | stream and slot CRUD, channel pages |
| Media proxy | server/media-proxy/*.js | VODs, clips, pastes, thumbnails via OpenVibe.Media |
| Auth | server/auth/auth.js, server/auth/permissions.js | JWT/API tokens, scopes, role ranks |
| Analytics | server/analytics/store.js | request analytics in the analytics_* tables within ADR-021 (see Analytics) |
Migrations#
The schema is migrations/NNNN_*.sql, applied at boot on the owner's direct connection (DATABASE_DIRECT_URL) before the service serves on the pooled runtime role (DATABASE_URL, PgBouncer in transaction mode). Each file names its phase (expand, migrate, contract); a contract migration names the expand it completes and waits out the N-1 window. A file that has run is never edited: the next change is a new file. 0002_live.sql was generated from production's SQLite schema at the switch to PostgreSQL (2026-10-08) and carries the compatibility functions the queries use (datetime(), julianday(), ov_now()).
Analytics (ADR-021)#
openvibe-shared/analytics (the one ADR-021 module for Live, Tools and Network, wired in server/index.js) records one row per finished request in the analytics_* tables of Live's PostgreSQL database and rolls rows up hourly and daily for the Network admin dashboards. What a raw row may carry is bound by ADR-021 (OpenVibe.Contracts docs/adr/ADR-021-analytics.md):
- Stored: event type, service, route template (the matched Express route, else
privacy.normalisePath:
no query string, ids/hashes/ULIDs →:id, the segment after words likevod,p,u,users→:param,/@name→/@:user), method, status, response time, a rotating session id, country (CDN header),
user-agent class (chrome/windows/desktop,bot:googlebot) plus browser/os/device, referer
origin only, bot flags, a signed-in flag, timestamp. - Never stored: IP address, user or subject id, city/precise location, the user-agent string, full
referer URLs. Theip,user_id,citycolumns remain for compatibility and are always NULL. - Session id: 16 random hex chars kept in memory against the visitor hash; new after 30 minutes idle
and at every UTC midnight. Not derived from any id. - Bot rate check: per-IP hit counters for the current and previous minute, in memory only (dropped
after 2 idle minutes).analytics_rate_trackingis emptied at boot and no longer written. - Unique visitors:
HMAC-SHA256(day salt, ip + "\n" + user agent), truncated to 16 hex chars. The salt is
random per UTC day (analytics_day_salts; deleted once the day is over). Hashes live only inanalytics_visitor_days, never in raw events, and are deleted by the first hourly aggregation after
their day ends — right after that day's final rollup — so none outlives its day by more than about an
hour. Rollups keep counts only, and a recompute never lowers a stored unique count. Raw-event
dashboards (sub-48 h summaries, realtime, top pages) count distinct sessions instead of IPs; "new vs
returning visitors" and dailynew_usersare no longer measured (NULL). - Retention: raw events older than 30 days are deleted nightly in batches (job
analytics-prune,pruneRawEventsPginserver/analytics/store.js); rollups are kept. - Opt-out: a request with
Sec-GPC: 1orDNT: 1is not recorded at all: no raw row, visitor hash,
session id or rate counter, so it is also missing from the rollups.
OpenVibe Integration#
SSO and wallet come from OpenVibe.Network; media storage and processing from OpenVibe.Media. Binding inter-service contracts are in ../CONTRACTS.md (OpenVibers workspace). Deployment: deploy.md. Security posture: ../SECURITY_AUDIT.md. Measurements: performance-audit.md.
Durable events (OpenVibe.Events)#
Live publishes through one transactional outbox (event_outbox, server/events/stream-events.js; off unless EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set). Every event is queued in the same PostgreSQL transaction as the change it describes.
| event | when | subject |
|---|---|---|
live.stream.started / live.stream.ended | a streams row goes live / ends | stream <id> |
live.release.deployed | the first boot that runs new commits (server/events/release-events.js), queued with the deploy_last_announced update; payload: service, release, commit, previous, commit_count, commits[] (≤ 40), deployed_at, notes_url | release <head sha> |
The chat deploy notice is Chat's: Live queues live.release.deployed and OpenVibe.Chat folds and stores the rolling card (C-84). Live keeps no chat table of its own.
Live consumes, each on its own endpoint with its own subscription secret, signature v2 only, applied once through the SDK inbox (idempotency_receipts):
| topics | endpoint | secret | subscribe with |
|---|---|---|---|
openre.session.* | /internal/openre-events | OPENRE_EVENTS_SECRET | OpenRestream's scripts/subscribe-live-events.js |
media.vod.*, media.clip.*, media.storage.* | /internal/media-events | MEDIA_EVENTS_SECRET | scripts/subscribe-media-events.js |
Media outcomes over Events#
Media reports vod.ready|failed, clip.ready|failed and storage.alert|recovered as media.* events. It writes each event in the state change's transaction, and Events delivers it to POST /internal/media-events (signature v2, MEDIA_EVENTS_SECRET). server/media-proxy/outcomes.js applies each outcome once (receipt media:<object type>:<object id>:<event id>, consumer live-media-outcomes), so a redelivery is a no-op. Only Live's tenant (MEDIA_APP_ID) counts, and media.object.uploaded is ignored.
This is the only path. The direct webhook (POST /internal/media-webhook, MEDIA_WEBHOOK_SECRET) carried the same outcomes during the transition. It was retired on 2026-10-10, after Events had delivered every one of Live's 123 media outcomes with no dead letters: Live's webhook_url was cleared in Media's apps row and seed, and the route, server/media-proxy/webhook.js, MEDIA_WEBHOOK_SECRET and MEDIA_EVENTS_AUTHORITY were deleted.
This page is rendered from docs/architecture.md in the OpenVibe.Live repository. Found a mistake? Edit it there.