openvibers/openrestream Commits

OpenRe.Stream: robots.txt names a sitemap, sitemap.xml and llms.txt (browser WHIP first); /metrics never proxied. (#13)

OP OpenVibers committed 7 Oct 2026 · 683900ba47 · 5 files, +133 −2

**Changes**
- `deploy/nginx/openre.stream.conf:93-94` — added `location = /metrics { return 404; }` (with the News-style comment) immediately before the apex vhost's catch-all, so proxied `/metrics` never reaches openre-api.
- `server/discovery.js:22-71` — new discovery router using `openvibe-shared/seo` helpers: `/robots.txt` (`seo.robotsTxt`, names `https://openre.stream/sitemap.xml`, disallows `/streams /sessions /destinations /auth/ /api/ /play/`), `/sitemap.xml` (`seo.sitemapXml` over the two public pages `/` and `/updates`), and `/llms.txt` (`seo.llmsTxt`, leading with browser WHIP go-live with no OBS, then RTMP and JSMPEG ingest, restream to RTMP/RTMPS/SRT, recording via OpenVibe.Media, playback, no follower minimum, alpha). All three use `cache.htmlHeaders()`, matching the repo's cache-policy rule.
- `server/app.js:7,154` — replaced the inline `/robots.txt` route (and its now-unused `cache` require) with `app.use('/', createDiscoveryRoutes({ config }))` mounted before the UI router.
- `test/discovery.test.js:11,25,38` — three route tests: robots.txt names the sitemap (plain text), sitemap.xml is XML and lists `/` and `/updates`, llms.txt is plain text and states browser WHIP.
- `test/deploy-conf.test.js:72-82` — parse-only test that the apex openre.stream vhost has `location = /metrics { return 404; }` before the catch-all.

Home page untouched.

**Tests** — `nice -n 15 ov test --no-cache test/discovery.test.js test/deploy-conf.test.js test/asset-cache.test.js test/drill-mode.test.js`:
```
✓ test/asset-cache.test.js   5.9 s
✓ test/deploy-conf.test.js   0.1 s
✓ test/discovery.test.js     7.8 s
✓ test/drill-mode.test.js   23.2 s
4 passed in 24 s.
```
(asset-cache and drill-mode were run because they reach robots.txt; both green. Nothing committed or pushed.)

Changes

ddiff --git a/deploy/nginx/openre.stream.conf b/deploy/nginx/openre.stream.conf
iindex 92e0c4a..756479a 100644
---- a/deploy/nginx/openre.stream.conf
++++ b/deploy/nginx/openre.stream.conf
@@@ -90,6 +90,9 @@ server {
proxy_set_header traceparent $http_traceparent;
}
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4500/metrics) ──
++ location = /metrics { return 404; }
++
location / {
proxy_pass http://127.0.0.1:4500;
proxy_http_version 1.1;
ddiff --git a/server/app.js b/server/app.js
iindex b1c0954..c5994d9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -4,7 +4,7 @@ const http = require('http');
const express = require('express');
const cookieParser = require('cookie-parser');
const contracts = require('openvibe-contracts');
--const cache = require('openvibe-shared/cache-policy');
++const { createDiscoveryRoutes } = require('./discovery');
const { createV1Router } = require('./api/v1');
const { createSsoRoutes } = require('./auth/sso');
const { createUiRouter } = require('./ui/routes');
@@@ -150,7 +150,8 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
});
app.use('/auth', createSsoRoutes({ config, auth, fetchImpl }));
-- app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send('User-agent: *\nAllow: /$\nDisallow: /streams\nDisallow: /sessions\nDisallow: /destinations\nDisallow: /api/\nDisallow: /play/\n'));
++ // Crawl and machine-readability artifacts (robots.txt, sitemap.xml, llms.txt): openvibe-shared/seo.
++ app.use('/', createDiscoveryRoutes({ config }));
app.use('/', createUiRouter({ rt, auth }));
app.use((req, res) => contracts.http.sendProblem(res, 404, 'openre.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
ddiff --git a/server/discovery.js b/server/discovery.js
nnew file mode 100644
iindex 0000000..5663e1e
---- /dev/null
++++ b/server/discovery.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * Crawl and machine-readability artifacts for openre.stream, built from openvibe-shared/seo the
++ * same way every other OpenVibe site builds them:
++ *
++ * GET /robots.txt welcomes search and AI crawlers, keeps the signed-in owner pages, sign-in,
++ * the API and playback out of the index, and always names the sitemap
++ * GET /sitemap.xml the public, server-rendered pages (server/ui/routes.js) only
++ * GET /llms.txt what OpenRe.Stream does today, in plain language
++ *
++ * Read-only and viewer-independent: built from the config (the public base URL) and nothing else.
++ */
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++const seo = require('openvibe-shared/seo');
++
++// Public pages only. /streams, /streams/:id, /sessions, /sessions/:id and /destinations/:id render
++// one signed-in owner's data (server/ui/routes.js), so they never appear in the sitemap.
++const PUBLIC_PATHS = ['/', '/updates'];
++const DISALLOW = ['/streams', '/sessions', '/destinations', '/auth/', '/api/', '/play/'];
++
++function createDiscoveryRoutes({ config }) {
++ const router = express.Router();
++ const abs = (p) => seo.absolute(p, config.baseUrl);
++
++ router.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(
++ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })));
++
++ router.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders()).send(
++ seo.sitemapXml(PUBLIC_PATHS.map((loc) => ({ loc: abs(loc) })))));
++
++ router.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(
++ seo.llmsTxt({
++ name: 'OpenRe.Stream',
++ summary: 'Live ingest and restream for the OpenVibe network. Go live from a browser over WHIP (WebRTC) with no OBS or encoder install, or publish with an RTMP encoder such as OBS or an ffmpeg MPEG-TS (JSMPEG) feed; OpenRe carries the session and restreams it to RTMP, RTMPS and SRT destinations.',
++ details: [
++ 'OpenRe.Stream is the ingest and restream authority behind OpenVibe.Live: channels, discovery and watch pages stay on openvibe.live, which plays an OpenRe session from its playback descriptor. OpenRe itself serves a JSON/REST API and a small server-rendered owner UI at openre.stream.',
++ '',
++ 'Ingest options, as the code and README describe them today:',
++ '- Browser WebRTC (WHIP, RFC 9725): POST /whip/<key> on ingest.openre.stream, one mediasoup router per session and WebSocket viewer signaling at /w/<session id>. The /b/<key> broadcaster signaling endpoint is what the OpenVibe.Live broadcast page speaks, so a streamer can go live from a browser without OBS.',
++ '- RTMP: any RTMP encoder, e.g. OBS, to rtmp://ingest.openre.stream:1936/live with the stream key shown once when the stream is created.',
++ '- JSMPEG: an ffmpeg MPEG-TS HTTP POST to ingest.openre.stream:9736/<key>/<width>/<height>/ with WebSocket viewers; JSMPEG sessions are not recorded.',
++ '',
++ 'Restream: a session can fan out to RTMP, RTMPS and SRT destinations (Twitch, YouTube, Kick or a custom server) with per-output health, logs, backoff and a rapid-crash circuit breaker. Recording: OpenRe asks OpenVibe.Media to record a VOD or clips for RTMP and WebRTC sessions (not JSMPEG). Playback: a live, non-private session is served as HTTP-FLV at https://openre.stream/play/<session id>.flv.',
++ '',
++ 'Ingest keys are random, stored only as a SHA-256 hash and shown once at creation or rotation. There is no follower minimum or eligibility threshold to go live: a signed-in OpenVibe account can create a stream. The service is alpha.',
++ ].join('\n'),
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'OpenRe.Stream home', url: abs('/') },
++ { title: 'What shipped', url: abs('/updates') },
++ { title: 'Source code', url: 'https://github.com/OpenVibers/OpenRe.Stream' },
++ ] },
++ { title: 'Machine-readable', links: [
++ { title: 'Sitemap', url: abs('/sitemap.xml') },
++ { title: 'robots.txt', url: abs('/robots.txt') },
++ ] },
++ ],
++ })));
++
++ return router;
++}
++
++module.exports = { createDiscoveryRoutes };
ddiff --git a/test/deploy-conf.test.js b/test/deploy-conf.test.js
iindex db4e8eb..c197983 100644
---- a/test/deploy-conf.test.js
++++ b/test/deploy-conf.test.js
@@@ -68,3 +68,15 @@ test('the marked section installs on its own: map + ingest server block, nothing
assert.match(live(section), /^map\s+\$http_upgrade\s+\$connection_upgrade/m, 'the map travels with the block');
assert.doesNotMatch(live(section), /limit_(req|conn)_zone/, 'the openre.stream zones stay out of it');
});
++
++test('the openre.stream vhost returns 404 for /metrics, before the catch-all', () => {
++ // The app also guards /metrics to loopback (server/app.js); every OpenVibers vhost blocks it
++ // at nginx too, so a proxied request never reaches the app.
++ const [apex] = serverBlocks(live(conf)).filter((b) => /^\s*server_name\s+openre\.stream\s*;/m.test(b) && /proxy_pass http:\/\/127\.0\.0\.1:4500;/m.test(b));
++ assert.ok(apex, 'the apex openre.stream server block that proxies to the API');
++ const metrics = apex.search(/^\s*location\s+=\s+\/metrics\s*\{\s*return\s+404;\s*\}/m);
++ const catchAll = apex.search(/^\s*location\s+\/\s*\{/m);
++ assert.ok(metrics >= 0, 'location = /metrics { return 404; } is present');
++ assert.ok(catchAll >= 0, 'the catch-all location / is present');
++ assert.ok(metrics < catchAll, 'the metrics block comes before the catch-all');
++});
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
nnew file mode 100644
iindex 0000000..80c4d63
---- /dev/null
++++ b/test/discovery.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++// The discovery routes (server/discovery.js) answer with the right content types: robots.txt
++// names the sitemap, sitemap.xml lists the public pages including /, and llms.txt describes what
++// OpenRe.Stream does today. All three come from openvibe-shared/seo, the shared toolkit.
++const assert = require('assert');
++const seo = require('openvibe-shared/seo');
++const { bootApi, request, suite } = require('./helpers');
++
++const t = suite('discovery');
++
++t('robots.txt welcomes crawlers, keeps the owner pages out, and names the sitemap', async () => {
++ const api = await bootApi({});
++ try {
++ const r = await request(api.base, 'GET', '/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.ok(r.text.includes(`Sitemap: ${seo.absolute('/sitemap.xml', api.config.baseUrl)}`), 'names the sitemap');
++ assert.ok(r.text.includes('Disallow: /streams'), 'signed-in owner pages stay out');
++ assert.ok(r.text.includes('Disallow: /api/'), 'the API stays out');
++ } finally {
++ await api.close();
++ }
++});
++
++t('sitemap.xml is XML and lists the public pages, including /', async () => {
++ const api = await bootApi({});
++ try {
++ const r = await request(api.base, 'GET', '/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /xml/);
++ assert.ok(r.text.includes(`<loc>${seo.absolute('/', api.config.baseUrl)}</loc>`), 'lists /');
++ assert.ok(r.text.includes(`<loc>${seo.absolute('/updates', api.config.baseUrl)}</loc>`), 'lists /updates');
++ } finally {
++ await api.close();
++ }
++});
++
++t('llms.txt is plain text and leads with browser WHIP go-live', async () => {
++ const api = await bootApi({});
++ try {
++ const r = await request(api.base, 'GET', '/llms.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.ok(r.text.startsWith('# OpenRe.Stream\n'), 'starts with the site heading');
++ assert.ok(/WHIP/.test(r.text) && /browser/i.test(r.text), 'browser WHIP go-live is stated');
++ } finally {
++ await api.close();
++ }
++});
++
++t.run();