openvibers/openrestream Commits
OpenRe.Stream: robots.txt names a sitemap, sitemap.xml and llms.txt (browser WHIP first); /metrics never proxied. (#13)
Changes
@@@ -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;@@@ -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 }));nnew file mode 100644@@@ -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 };@@@ -68,3 +68,15 @@ test('the marked section installs on its own: map + ingest server block, nothingassert.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');++});nnew file mode 100644@@@ -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();
@@ -90,6 +90,9 @@ server {
@@ -90,6 +90,9 @@ server {
proxy_set_header traceparent $http_traceparent;
proxy_set_header traceparent $http_traceparent;
}
}
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4500/metrics) ──
+ location = /metrics { return 404; }
+
location / {
location / {
proxy_pass http://127.0.0.1:4500;
proxy_pass http://127.0.0.1:4500;
proxy_http_version 1.1;
proxy_http_version 1.1;
@@ -4,7 +4,7 @@ const http = require('http');
@@ -4,7 +4,7 @@ const http = require('http');
const express = require('express');
const express = require('express');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const cache = require('openvibe-shared/cache-policy');
+const { createDiscoveryRoutes } = require('./discovery');
const { createV1Router } = require('./api/v1');
const { createV1Router } = require('./api/v1');
const { createSsoRoutes } = require('./auth/sso');
const { createSsoRoutes } = require('./auth/sso');
const { createUiRouter } = require('./ui/routes');
const { createUiRouter } = require('./ui/routes');
@@ -150,7 +150,8 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
@@ -150,7 +150,8 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
});
});
app.use('/auth', createSsoRoutes({ config, auth, 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('/', 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 }));
app.use((req, res) => contracts.http.sendProblem(res, 404, 'openre.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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 };
@@ -68,3 +68,15 @@ test('the marked section installs on its own: map + ingest server block, nothing
@@ -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.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');
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');
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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();