openvibers/openvibe.network Commits

nginx generator: Live's ingest hostnames, connection budgets, static cache, gzip and query-less logs

OP OpenVibers committed 17 Sept 2026 · b5f36b568b · 3 files, +96 −3

Generated config for OpenVibe.Live now matches deploy/nginx/openvibe.live.conf in
that repo, checked by test/nginx-generator-live.test.js (in npm test).

Changes

ddiff --git a/package.json b/package.json
iindex 361464fe..7159c29f 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/deploy/nginx-generator.js b/server/deploy/nginx-generator.js
iindex 7410b224..99ba178d 100644
---- a/server/deploy/nginx-generator.js
++++ b/server/deploy/nginx-generator.js
@@@ -96,12 +96,24 @@ const DEFAULT_SERVICE_MAP = {
live: {
port: 3000,
domains: ['openvibe.live', 'www.openvibe.live'],
++ // WHIP ingest and the RTMP-info hostname are served by the same app.
++ extraServerNames: ['whip.openvibe.live', 'ingest.openvibe.live'],
wildcardDomain: 'openvibe.live',
maxBodySize: '3m',
rateZones: [
{ name: 'streamer_api', rate: '10r/s' },
{ name: 'streamer_login', rate: '5r/m' },
],
++ // Two per-IP connection budgets: long-lived WebSockets (5-6 per open tab) must not use up the
++ // budget a page load needs for its stylesheets and scripts. Mirrors OpenVibe.Live's
++ // deploy/nginx/openvibe.live.conf, which is what production runs.
++ connZones: ['addr_limit', 'asset_limit'],
++ connLimit: { zone: 'addr_limit', n: 256 },
++ // Static responses cached in front of Node; Node decides cacheability (content-hashed ?v=).
++ proxyCaches: [{ path: '/var/cache/nginx/openvibe-live', zone: 'live_static', size: '20m', maxSize: '1g', inactive: '30d' }],
++ gzip: true,
++ // WebSocket URLs carry session tokens in the query string; keep them out of access logs.
++ logWithoutQuery: true,
locations: [
{
match: '= /api/auth/register',
@@@ -138,9 +150,15 @@ const DEFAULT_SERVICE_MAP = {
match: '/ws/',
websocket: true,
},
++ {
++ match: '~ ^/(?!api/|ws/|data/|media/).*\\.(?:css|js|mjs|map|woff2?|ttf|otf|svg|png|jpe?g|gif|ico|webp|mp3|wav)$',
++ connLimit: { zone: 'asset_limit', n: 100 },
++ proxyCache: 'live_static',
++ },
],
defaultLocationExtras: {
websocket: true,
++ cfConnectingIp: true,
headers: {
'Permissions-Policy': 'camera=*, microphone=*, display-capture=*',
},
@@@ -367,6 +385,10 @@ function generateProxyBlock(port, loc = {}) {
lines.push('chunked_transfer_encoding off;');
}
++ if (loc.cfConnectingIp) {
++ lines.push('proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;');
++ }
++
if (loc.readTimeout) {
lines.push(`proxy_read_timeout ${loc.readTimeout};`);
}
@@@ -394,6 +416,19 @@ function generateLocationBlock(port, loc) {
lines.push(`client_max_body_size ${loc.maxBodySize};`);
}
++ if (loc.connLimit) {
++ lines.push(`limit_conn ${loc.connLimit.zone} ${loc.connLimit.n};`);
++ }
++
++ if (loc.proxyCache) {
++ // Only responses the app marks cacheable are stored (nginx honours Cache-Control).
++ lines.push(`proxy_cache ${loc.proxyCache};`);
++ lines.push('proxy_cache_key "$host$uri$is_args$args";');
++ lines.push('proxy_cache_lock on;');
++ lines.push('proxy_cache_use_stale error timeout updating http_502 http_503;');
++ lines.push('add_header X-Cache $upstream_cache_status always;');
++ }
++
lines.push(generateProxyBlock(port, loc));
if (loc.cacheTime) {
@@@ -437,7 +472,7 @@ function parseDurationToSeconds(dur) {
*/
function generateServiceConfig(serviceId, svc, opts = {}) {
const lines = [];
-- const allDomains = [...(svc.domains || [])];
++ const allDomains = [...(svc.domains || []), ...(svc.extraServerNames || [])];
if (svc.wildcardDomain && !svc.parentDomain) {
// Add wildcard to server_name if this service owns the domain
if (!allDomains.includes(`*.${svc.wildcardDomain}`)) {
@@@ -462,6 +497,18 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
lines.push('');
}
++ if (svc.logWithoutQuery) {
++ lines.push(`log_format ${serviceId}_noquery '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent "$http_referer" "$http_user_agent"';`);
++ }
++ // Connection budgets and response caches
++ for (const zone of svc.connZones || []) {
++ lines.push(`limit_conn_zone $binary_remote_addr zone=${zone}:10m;`);
++ }
++ for (const c of svc.proxyCaches || []) {
++ lines.push(`proxy_cache_path ${c.path} levels=1:2 keys_zone=${c.zone}:${c.size || '10m'} max_size=${c.maxSize || '512m'} inactive=${c.inactive || '7d'} use_temp_path=off;`);
++ }
++ if ((svc.connZones || []).length || (svc.proxyCaches || []).length) lines.push('');
++
// HTTP → HTTPS redirect
lines.push('# HTTP → HTTPS redirect');
lines.push('server {');
@@@ -494,7 +541,7 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
// Logs
const logName = svc.domains?.[0]?.replace(/\./g, '-') || serviceId;
-- lines.push(` access_log /var/log/nginx/${logName}.access.log;`);
++ lines.push(` access_log /var/log/nginx/${logName}.access.log${svc.logWithoutQuery ? ` ${serviceId}_noquery` : ''};`);
lines.push(` error_log /var/log/nginx/${logName}.error.log;`);
lines.push('');
@@@ -502,6 +549,22 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
lines.push(` client_max_body_size ${svc.maxBodySize || '5m'};`);
lines.push('');
++ if (svc.connLimit) {
++ lines.push(` limit_conn ${svc.connLimit.zone} ${svc.connLimit.n};`);
++ lines.push('');
++ }
++
++ if (svc.gzip) {
++ // Compress text on the hop to the CDN too; the global config does not compress proxied responses.
++ lines.push(' gzip on;');
++ lines.push(' gzip_proxied any;');
++ lines.push(' gzip_vary on;');
++ lines.push(' gzip_comp_level 5;');
++ lines.push(' gzip_min_length 1024;');
++ lines.push(' gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml text/xml;');
++ lines.push('');
++ }
++
// Security headers
if (svc.headers) {
for (const [k, v] of Object.entries(svc.headers)) {
ddiff --git a/test/nginx-generator-live.test.js b/test/nginx-generator-live.test.js
nnew file mode 100644
iindex 00000000..5cbf5b90
---- /dev/null
++++ b/test/nginx-generator-live.test.js
@@@ -0,0 +1,30 @@
++/**
++ * The generated openvibe.live config must carry what production's hand-maintained config
++ * (OpenVibe.Live deploy/nginx/openvibe.live.conf) depends on, or regenerating it from the admin
++ * panel silently drops WHIP ingest, the connection budgets, the static cache and compression.
++ *
++ * node test/nginx-generator-live.test.js
++ */
++'use strict';
++const assert = require('assert');
++const g = require('../server/deploy/nginx-generator');
++
++const conf = g.generateServiceConfig('live', g.DEFAULT_SERVICE_MAP.live, { sslEnabled: true, certPath: '/c/f.pem', keyPath: '/c/k.pem' });
++const must = [
++ ['WHIP hostname', /server_name [^;]*whip\.openvibe\.live/],
++ ['ingest hostname', /server_name [^;]*ingest\.openvibe\.live/],
++ ['socket connection budget', /limit_conn_zone \$binary_remote_addr zone=addr_limit:10m;[\s\S]*limit_conn addr_limit 256;/],
++ ['asset connection budget', /limit_conn_zone \$binary_remote_addr zone=asset_limit:10m;[\s\S]*limit_conn asset_limit 100;/],
++ ['static cache zone', /proxy_cache_path \/var\/cache\/nginx\/openvibe-live [^;]*keys_zone=live_static:/],
++ ['static cache on the asset location', /location ~ \^\/\(\?!api\/\|ws\/\|data\/\|media\/\)[^{]*\{[^}]*proxy_cache live_static;/],
++ ['gzip for proxied text', /gzip_proxied any;/],
++ ['upload body size', /location ~ \^\/api\/vods\/\(upload\|clips\|stream\/\) \{[^}]*client_max_body_size 500m;/],
++ ['websocket upgrade on catch-all', /location \/ \{[^}]*proxy_set_header Upgrade \$http_upgrade;/],
++ ['Cloudflare client IP forwarded', /proxy_set_header CF-Connecting-IP \$http_cf_connecting_ip;/],
++ ['access log without query strings (tokens)', /log_format live_noquery [^;]*\$uri[^;]*;[\s\S]*access_log [^;]* live_noquery;/],
++ ['capture permissions header', /Permissions-Policy "camera=\*, microphone=\*, display-capture=\*"/],
++];
++for (const [name, re] of must) assert.ok(re.test(conf), `generated live config is missing: ${name}`);
++// The asset regex must not swallow /api/ or /ws/ (a regex location beats prefix locations).
++assert.ok(!/location ~ \^\/\.\*\\\.\(\?:css/.test(conf), 'asset location must exclude /api/ and /ws/');
++console.log(`nginx generator (live): ${must.length} checks passed`);