openvibers/openrestream Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#136)
Changes
@@@ -315,5 +315,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.222.0- openvibe-sdk: v0.67.0--- openvibe-shared: v4.29.0++- openvibe-shared: v4.34.0<!-- versions:end -->@@@ -10,7 +10,7 @@"domain": "openre.stream","contracts": "openvibe-contracts v0.222.0","sdk": "openvibe-sdk v0.67.0",-- "shared": "openvibe-shared v4.29.0",++ "shared": "openvibe-shared v4.34.0","protocols": {"rtmp": "ingest ported (openre-rtmp-ingest)","restream": "RTMP source to RTMP/RTMPS/SRT destinations ported (openre-restream-worker)",@@@ -17,7 +17,7 @@"node-media-server": "2.7.4","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0","sdp-transform": "^3.0.0","ws": "^8.18.0"@@@ -30,6 +30,12 @@"node": ">=22.12.0"}},++ "node_modules/@acemir/cssom": {++ "version": "0.9.32",++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",++ "license": "MIT"++ },"node_modules/@electric-sql/pglite": {"version": "0.5.8","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",@@@ -481,6 +487,19 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/buffer-equal-constant-time": {"version": "1.0.1","resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",@@@ -617,6 +636,39 @@"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==","license": "MIT"},++ "node_modules/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/data-uri-to-buffer": {"version": "4.0.1","resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",@@@ -685,6 +737,73 @@"node": ">=6"}},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -735,6 +854,18 @@"node": ">= 0.8"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/es-define-property": {"version": "1.0.1","resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",@@@ -1065,6 +1196,110 @@"node": ">= 0.4"}},++ "node_modules/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -1273,6 +1508,30 @@"safe-buffer": "^5.0.1"}},++ "node_modules/linkedom": {++ "version": "0.18.14",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",++ "license": "ISC",++ "dependencies": {++ "@acemir/cssom": "^0.9.32",++ "css-select": "^7.0.0",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },"node_modules/lodash": {"version": "4.18.1","resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",@@@ -1613,6 +1872,22 @@"node": ">=8.0.0"}},++ "node_modules/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -1682,12 +1957,13 @@}},"node_modules/openvibe-shared": {-- "version": "4.29.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.0",-- "integrity": "sha512-IcWh6eMwoLMoYjf2dHVGdVCNv1B0X2bm/2ZHLBOYCfF1Y1Nawn2wL+eMvo57JDjYL1Vm9RnL3bK1eZamCW1zQA==",++ "version": "4.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",++ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==","license": "MIT","dependencies": {-- "jsonwebtoken": "^9.0.0"++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"},"bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -2253,6 +2529,12 @@"node": ">= 0.6"}},++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },"node_modules/unpipe": {"version": "1.0.0","resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",@@@ -33,7 +33,7 @@"node-media-server": "2.7.4","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0","sdp-transform": "^3.0.0","ws": "^8.18.0"nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// What openre.stream tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this host really serves: the front page and the update log, the JSON API under /api/v1,++// llms.txt and the sitemap. Creating a stream and its keys needs an OpenVibe account (Network OIDC); services call++// the API with a Network client-credentials token. No OpenAPI document, documentation page or MCP endpoint is++// published here, so none is named.++module.exports = {++ id: 'openre',++ name: 'OpenRestream',++ purpose: 'Send one stream from OBS or any RTMP encoder and restream it to Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once, with the person\'s OpenVibe Live channel on by default; free and open source, with no follower minimum.',++ home: 'https://openre.stream',++ site: 'openre.stream',++ api: '/api/v1',++ policy: {++ auth: ['none', 'Network OIDC', 'Network service token'],++ },++};nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++// The Markdown twins of OpenRestream's public pages (openvibe-shared/agent-layer): the front page and the update log.++//++// A twin is the page's own render: the UI router answers the request in-process, signed out, so a twin cannot drift++// from the page a browser gets and never carries a signed-in owner's view. The streams, one stream, destinations,++// sessions and one session render a person's own data and have no twin.++const TWIN_PATH_RE = /^\/(?:updates)?$/;++++/** A request as an anonymous browser makes it: no session, no cookies. */++function anonymousRequest(path, query, host) {++ const search = query && query.toString() ? `?${query.toString()}` : '';++ const url = `${path}${search}`;++ return {++ method: 'GET', url, originalUrl: url, path, params: {}, query: query ? Object.fromEntries(query) : {},++ headers: { accept: 'text/html', host }, cookies: {}, body: undefined, protocol: 'https', secure: true,++ ip: '127.0.0.1', ips: ['127.0.0.1'], hostname: host,++ get(name) { return this.headers[String(name || '').toLowerCase()] || ''; },++ accepts(types) {++ if (!Array.isArray(types)) return 'text/html';++ return types.some((t) => t === 'html' || t === 'text/html') ? 'html' : false;++ },++ is() { return false; },++ };++}++++/** A response that keeps what the page wrote, so the twin can be built from it. */++function captureResponse() {++ return {++ statusCode: 200, headers: {}, body: null, headersSent: false,++ status(code) { this.statusCode = code; return this; },++ set(name, value) { this.headers[String(name)] = value; return this; },++ setHeader(name, value) { this.headers[String(name)] = value; return this; },++ getHeader(name) { return this.headers[String(name)]; },++ get(name) { return this.headers[String(name)]; },++ removeHeader(name) { delete this.headers[String(name)]; },++ type(value) { this.headers['Content-Type'] = value === 'html' ? 'text/html; charset=utf-8' : String(value); return this; },++ location(url) { this.headers.Location = url; return this; },++ send(body) { this.body = body; this.headersSent = true; return this; },++ json(body) { this.body = JSON.stringify(body); this.headersSent = true; return this; },++ redirect(code, url) { this.statusCode = code; this.headers.Location = url; this.body = ''; this.headersSent = true; return this; },++ end(body) { if (body !== undefined) this.body = body; this.headersSent = true; return this; },++ cookie() { return this; }, clearCookie() { return this; },++ };++}++++function createAgentPages({ router, host }) {++ async function renderPublicPage(path, { query } = {}) {++ const p = String(path || '/').replace(/\/+$/, '') || '/';++ if (!TWIN_PATH_RE.test(p)) return null;++ const req = anonymousRequest(p, query, host);++ req.viewer = { kind: 'anonymous' };++ req.caller = { kind: 'anonymous' };++ const res = captureResponse();++ await new Promise((resolve) => {++ let settled = false;++ const finish = () => { if (!settled) { settled = true; resolve(); } };++ const send = res.send.bind(res);++ res.send = (body) => { send(body); finish(); return res; };++ const redirect = res.redirect.bind(res);++ res.redirect = (code, url) => { redirect(code, url); finish(); return res; };++ try { router(req, res, () => finish()); } catch { finish(); }++ const timer = setTimeout(finish, 10000);++ if (timer.unref) timer.unref();++ });++ // A page that needs a person, a missing page or a page that is not public has no twin.++ if (res.statusCode !== 200 || res.body == null) return null;++ const html = Buffer.isBuffer(res.body) ? res.body.toString('utf8') : String(res.body);++ if (!/^text\/html/i.test(String(res.headers['Content-Type'] || ''))) return null;++ return { html };++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages, TWIN_PATH_RE };@@@ -163,7 +163,28 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {app.use('/auth', createSsoRoutes({ config, auth, fetchImpl }));// Crawl and machine-readability artifacts (robots.txt, sitemap.xml, llms.txt): openvibe-shared/seo.app.use('/', createDiscoveryRoutes({ config, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));-- app.use('/', createUiRouter({ rt, auth, limits: actorLimits, liveLink }));++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the two++ // public pages (the front page and the update log). Entered only for the manifest exactly (the rest of++ // /.well-known/ belongs to the app) and a twin request — a `.md` path, ?format=markdown or an explicit++ // text/markdown preference — because the layer renders pages signed out and the streams, destinations and++ // sessions pages are one owner's data. It comes before the UI routes so `/index.md` is not read as a missing++ // page; the pages name their own twins in the head and the Link header (server/ui/layout.js, server/ui/routes.js).++ {++ const uiRoutes = createUiRouter({ rt, auth, limits: actorLimits, liveLink });++ const agentRouter = express.Router();++ require('openvibe-shared/agent-layer').mount(agentRouter, {++ descriptor: require('./agent-descriptor'),++ render: require('./agent-pages').createAgentPages({ router: uiRoutes, host: new URL(config.baseUrl).hostname }).renderPublicPage,++ });++ app.use('/', (req, res, next) => {++ if (req.path === '/.well-known/agent.json') return agentRouter(req, res, next);++ const p = String(req.path || '');++ if (p.endsWith('.md') || (req.query && req.query.format === 'markdown')++ || req.accepts(['html', 'text/markdown']) === 'text/markdown') return agentRouter(req, res, next);++ return next();++ });++ app.use('/', uiRoutes);++ }app.use((req, res) => contracts.http.sendProblem(res, 404, 'openre.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));// eslint-disable-next-line no-unused-vars@@@ -24,7 +24,8 @@ function createDiscoveryRoutes({ config, releaseLastmod }) {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 })));++ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`));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), lastmod: releaseLastmod })))));@@@ -2,6 +2,8 @@const ovServe = require('openvibe-shared/serve');const seo = require('openvibe-shared/seo');const social = require('openvibe-shared/social');++const { TWIN_PATH_RE } = require('../agent-pages');++const { linkHeaderTags } = require('openvibe-shared/agent-layer');/*** Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared* OpenVibe Frame (theme loader, navbar and footer from openvibe.network, footer SSR from the@@@ -69,6 +71,8 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,image: indexable ? social.url('openre') : null,jsonLd: indexable ? jsonLd : null,});++ // A public page names its Markdown twin (openvibe-shared/agent-layer); a signed-in owner page has none.++ const twinHead = TWIN_PATH_RE.test(canonicalPath.split('?')[0]) ? linkHeaderTags(canonical, '/llms.txt').head : '';const nav = {service: 'openre',apiBase: NETWORK_URL,@@@ -98,6 +102,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${seoHead}++${twinHead}${icon}<script src="${ovServe.url('theme-loader.js')}" defer></script><style>${CSS}</style>@@@ -20,6 +20,8 @@ const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const express = require('express');const { renderPage, esc } = require('./layout');++const { TWIN_PATH_RE } = require('../agent-pages');++const { linkHeaderTags } = require('openvibe-shared/agent-layer');const { StoreError } = require('../store/definitions');const { testDestination } = require('../destination-test');@@@ -61,7 +63,13 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {// The API's per-person limits count these form posts too (a test, a new stream, a rotation): one budget per person.if (limits) router.use(limits);-- const page = (req, res, o, status = 200) => res.status(status).type('html').send(renderPage({ ...o, user: req.caller.kind === 'user' ? req.caller : null, config }));++ const page = (req, res, o, status = 200) => {++ // The Link header names the same twin as the head, where the site sets headers; a signed-in page has none.++ if (TWIN_PATH_RE.test(String(o.canonicalPath || '/').split('?')[0])) {++ res.set('Link', linkHeaderTags(config.baseUrl + (o.canonicalPath || '/'), '/llms.txt').header);++ }++ return res.status(status).type('html').send(renderPage({ ...o, user: req.caller.kind === 'user' ? req.caller : null, config }));++ };const csrfField = (req) => `<input type="hidden" name="_csrf" value="${esc(csrfFor(req.caller.token))}">`;const form = (req, action, inner, { cls = '', confirm } = {}) => `<form method="post" action="${esc(action)}" class="${cls}"${confirm ? ` onsubmit="return confirm('${esc(confirm)}')"` : ''}>${csrfField(req)}${inner}</form>`;
@@ -315,5 +315,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -315,5 +315,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.29.0
+- openvibe-shared: v4.34.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
"domain": "openre.stream",
"domain": "openre.stream",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
- "shared": "openvibe-shared v4.29.0",
+ "shared": "openvibe-shared v4.34.0",
"protocols": {
"protocols": {
"rtmp": "ingest ported (openre-rtmp-ingest)",
"rtmp": "ingest ported (openre-rtmp-ingest)",
"restream": "RTMP source to RTMP/RTMPS/SRT destinations ported (openre-restream-worker)",
"restream": "RTMP source to RTMP/RTMPS/SRT destinations ported (openre-restream-worker)",
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"node-media-server": "2.7.4",
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -30,6 +30,12 @@
@@ -30,6 +30,12 @@
"node": ">=22.12.0"
"node": ">=22.12.0"
}
}
},
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.32",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
+ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
+ "license": "MIT"
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@ -481,6 +487,19 @@
@@ -481,6 +487,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -617,6 +636,39 @@
@@ -617,6 +636,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/data-uri-to-buffer": {
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -685,6 +737,73 @@
@@ -685,6 +737,73 @@
"node": ">=6"
"node": ">=6"
}
}
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -735,6 +854,18 @@
@@ -735,6 +854,18 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1065,6 +1196,110 @@
@@ -1065,6 +1196,110 @@
"node": ">= 0.4"
"node": ">= 0.4"
}
}
},
},
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1273,6 +1508,30 @@
@@ -1273,6 +1508,30 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/linkedom": {
+ "version": "0.18.14",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
+ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
+ "license": "ISC",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.32",
+ "css-select": "^7.0.0",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash": {
"node_modules/lodash": {
"version": "4.18.1",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1613,6 +1872,22 @@
@@ -1613,6 +1872,22 @@
"node": ">=8.0.0"
"node": ">=8.0.0"
}
}
},
},
+ "node_modules/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1682,12 +1957,13 @@
@@ -1682,12 +1957,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.29.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.0",
- "integrity": "sha512-IcWh6eMwoLMoYjf2dHVGdVCNv1B0X2bm/2ZHLBOYCfF1Y1Nawn2wL+eMvo57JDjYL1Vm9RnL3bK1eZamCW1zQA==",
+ "version": "4.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
+ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -2253,6 +2529,12 @@
@@ -2253,6 +2529,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
"node_modules/unpipe": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -33,7 +33,7 @@
@@ -33,7 +33,7 @@
"node-media-server": "2.7.4",
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// What openre.stream tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this host really serves: the front page and the update log, the JSON API under /api/v1,
+// llms.txt and the sitemap. Creating a stream and its keys needs an OpenVibe account (Network OIDC); services call
+// the API with a Network client-credentials token. No OpenAPI document, documentation page or MCP endpoint is
+// published here, so none is named.
+module.exports = {
+ id: 'openre',
+ name: 'OpenRestream',
+ purpose: 'Send one stream from OBS or any RTMP encoder and restream it to Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once, with the person\'s OpenVibe Live channel on by default; free and open source, with no follower minimum.',
+ home: 'https://openre.stream',
+ site: 'openre.stream',
+ api: '/api/v1',
+ policy: {
+ auth: ['none', 'Network OIDC', 'Network service token'],
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+// The Markdown twins of OpenRestream's public pages (openvibe-shared/agent-layer): the front page and the update log.
+//
+// A twin is the page's own render: the UI router answers the request in-process, signed out, so a twin cannot drift
+// from the page a browser gets and never carries a signed-in owner's view. The streams, one stream, destinations,
+// sessions and one session render a person's own data and have no twin.
+const TWIN_PATH_RE = /^\/(?:updates)?$/;
+
+/** A request as an anonymous browser makes it: no session, no cookies. */
+function anonymousRequest(path, query, host) {
+ const search = query && query.toString() ? `?${query.toString()}` : '';
+ const url = `${path}${search}`;
+ return {
+ method: 'GET', url, originalUrl: url, path, params: {}, query: query ? Object.fromEntries(query) : {},
+ headers: { accept: 'text/html', host }, cookies: {}, body: undefined, protocol: 'https', secure: true,
+ ip: '127.0.0.1', ips: ['127.0.0.1'], hostname: host,
+ get(name) { return this.headers[String(name || '').toLowerCase()] || ''; },
+ accepts(types) {
+ if (!Array.isArray(types)) return 'text/html';
+ return types.some((t) => t === 'html' || t === 'text/html') ? 'html' : false;
+ },
+ is() { return false; },
+ };
+}
+
+/** A response that keeps what the page wrote, so the twin can be built from it. */
+function captureResponse() {
+ return {
+ statusCode: 200, headers: {}, body: null, headersSent: false,
+ status(code) { this.statusCode = code; return this; },
+ set(name, value) { this.headers[String(name)] = value; return this; },
+ setHeader(name, value) { this.headers[String(name)] = value; return this; },
+ getHeader(name) { return this.headers[String(name)]; },
+ get(name) { return this.headers[String(name)]; },
+ removeHeader(name) { delete this.headers[String(name)]; },
+ type(value) { this.headers['Content-Type'] = value === 'html' ? 'text/html; charset=utf-8' : String(value); return this; },
+ location(url) { this.headers.Location = url; return this; },
+ send(body) { this.body = body; this.headersSent = true; return this; },
+ json(body) { this.body = JSON.stringify(body); this.headersSent = true; return this; },
+ redirect(code, url) { this.statusCode = code; this.headers.Location = url; this.body = ''; this.headersSent = true; return this; },
+ end(body) { if (body !== undefined) this.body = body; this.headersSent = true; return this; },
+ cookie() { return this; }, clearCookie() { return this; },
+ };
+}
+
+function createAgentPages({ router, host }) {
+ async function renderPublicPage(path, { query } = {}) {
+ const p = String(path || '/').replace(/\/+$/, '') || '/';
+ if (!TWIN_PATH_RE.test(p)) return null;
+ const req = anonymousRequest(p, query, host);
+ req.viewer = { kind: 'anonymous' };
+ req.caller = { kind: 'anonymous' };
+ const res = captureResponse();
+ await new Promise((resolve) => {
+ let settled = false;
+ const finish = () => { if (!settled) { settled = true; resolve(); } };
+ const send = res.send.bind(res);
+ res.send = (body) => { send(body); finish(); return res; };
+ const redirect = res.redirect.bind(res);
+ res.redirect = (code, url) => { redirect(code, url); finish(); return res; };
+ try { router(req, res, () => finish()); } catch { finish(); }
+ const timer = setTimeout(finish, 10000);
+ if (timer.unref) timer.unref();
+ });
+ // A page that needs a person, a missing page or a page that is not public has no twin.
+ if (res.statusCode !== 200 || res.body == null) return null;
+ const html = Buffer.isBuffer(res.body) ? res.body.toString('utf8') : String(res.body);
+ if (!/^text\/html/i.test(String(res.headers['Content-Type'] || ''))) return null;
+ return { html };
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages, TWIN_PATH_RE };
@@ -163,7 +163,28 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
@@ -163,7 +163,28 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
app.use('/auth', createSsoRoutes({ config, auth, fetchImpl }));
app.use('/auth', createSsoRoutes({ config, auth, fetchImpl }));
// Crawl and machine-readability artifacts (robots.txt, sitemap.xml, llms.txt): openvibe-shared/seo.
// Crawl and machine-readability artifacts (robots.txt, sitemap.xml, llms.txt): openvibe-shared/seo.
app.use('/', createDiscoveryRoutes({ config, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
app.use('/', createDiscoveryRoutes({ config, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
- app.use('/', createUiRouter({ rt, auth, limits: actorLimits, liveLink }));
+ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the two
+ // public pages (the front page and the update log). Entered only for the manifest exactly (the rest of
+ // /.well-known/ belongs to the app) and a twin request — a `.md` path, ?format=markdown or an explicit
+ // text/markdown preference — because the layer renders pages signed out and the streams, destinations and
+ // sessions pages are one owner's data. It comes before the UI routes so `/index.md` is not read as a missing
+ // page; the pages name their own twins in the head and the Link header (server/ui/layout.js, server/ui/routes.js).
+ {
+ const uiRoutes = createUiRouter({ rt, auth, limits: actorLimits, liveLink });
+ const agentRouter = express.Router();
+ require('openvibe-shared/agent-layer').mount(agentRouter, {
+ descriptor: require('./agent-descriptor'),
+ render: require('./agent-pages').createAgentPages({ router: uiRoutes, host: new URL(config.baseUrl).hostname }).renderPublicPage,
+ });
+ app.use('/', (req, res, next) => {
+ if (req.path === '/.well-known/agent.json') return agentRouter(req, res, next);
+ const p = String(req.path || '');
+ if (p.endsWith('.md') || (req.query && req.query.format === 'markdown')
+ || req.accepts(['html', 'text/markdown']) === 'text/markdown') return agentRouter(req, res, next);
+ return next();
+ });
+ app.use('/', uiRoutes);
+ }
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 }));
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
@@ -24,7 +24,8 @@ function createDiscoveryRoutes({ config, releaseLastmod }) {
@@ -24,7 +24,8 @@ function createDiscoveryRoutes({ config, releaseLastmod }) {
const abs = (p) => seo.absolute(p, config.baseUrl);
const abs = (p) => seo.absolute(p, config.baseUrl);
router.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(
router.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(
- seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })));
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
+ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`));
router.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders()).send(
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), lastmod: releaseLastmod })))));
seo.sitemapXml(PUBLIC_PATHS.map((loc) => ({ loc: abs(loc), lastmod: releaseLastmod })))));
@@ -2,6 +2,8 @@
@@ -2,6 +2,8 @@
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const social = require('openvibe-shared/social');
const social = require('openvibe-shared/social');
+const { TWIN_PATH_RE } = require('../agent-pages');
+const { linkHeaderTags } = require('openvibe-shared/agent-layer');
/**
/**
* Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared
* Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared
* OpenVibe Frame (theme loader, navbar and footer from openvibe.network, footer SSR from the
* OpenVibe Frame (theme loader, navbar and footer from openvibe.network, footer SSR from the
@@ -69,6 +71,8 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
@@ -69,6 +71,8 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
image: indexable ? social.url('openre') : null,
image: indexable ? social.url('openre') : null,
jsonLd: indexable ? jsonLd : null,
jsonLd: indexable ? jsonLd : null,
});
});
+ // A public page names its Markdown twin (openvibe-shared/agent-layer); a signed-in owner page has none.
+ const twinHead = TWIN_PATH_RE.test(canonicalPath.split('?')[0]) ? linkHeaderTags(canonical, '/llms.txt').head : '';
const nav = {
const nav = {
service: 'openre',
service: 'openre',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
@@ -98,6 +102,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
@@ -98,6 +102,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
<meta charset="utf-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
${seoHead}
${seoHead}
+${twinHead}
${icon}
${icon}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<style>${CSS}</style>
<style>${CSS}</style>
@@ -20,6 +20,8 @@ const frame = require('openvibe-shared/frame');
@@ -20,6 +20,8 @@ const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const express = require('express');
const express = require('express');
const { renderPage, esc } = require('./layout');
const { renderPage, esc } = require('./layout');
+const { TWIN_PATH_RE } = require('../agent-pages');
+const { linkHeaderTags } = require('openvibe-shared/agent-layer');
const { StoreError } = require('../store/definitions');
const { StoreError } = require('../store/definitions');
const { testDestination } = require('../destination-test');
const { testDestination } = require('../destination-test');
@@ -61,7 +63,13 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -61,7 +63,13 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
// The API's per-person limits count these form posts too (a test, a new stream, a rotation): one budget per person.
// The API's per-person limits count these form posts too (a test, a new stream, a rotation): one budget per person.
if (limits) router.use(limits);
if (limits) router.use(limits);
- const page = (req, res, o, status = 200) => res.status(status).type('html').send(renderPage({ ...o, user: req.caller.kind === 'user' ? req.caller : null, config }));
+ const page = (req, res, o, status = 200) => {
+ // The Link header names the same twin as the head, where the site sets headers; a signed-in page has none.
+ if (TWIN_PATH_RE.test(String(o.canonicalPath || '/').split('?')[0])) {
+ res.set('Link', linkHeaderTags(config.baseUrl + (o.canonicalPath || '/'), '/llms.txt').header);
+ }
+ return res.status(status).type('html').send(renderPage({ ...o, user: req.caller.kind === 'user' ? req.caller : null, config }));
+ };
const csrfField = (req) => `<input type="hidden" name="_csrf" value="${esc(csrfFor(req.caller.token))}">`;
const csrfField = (req) => `<input type="hidden" name="_csrf" value="${esc(csrfFor(req.caller.token))}">`;
const form = (req, action, inner, { cls = '', confirm } = {}) => `<form method="post" action="${esc(action)}" class="${cls}"${confirm ? ` onsubmit="return confirm('${esc(confirm)}')"` : ''}>${csrfField(req)}${inner}</form>`;
const form = (req, action, inner, { cls = '', confirm } = {}) => `<form method="post" action="${esc(action)}" class="${cls}"${confirm ? ` onsubmit="return confirm('${esc(confirm)}')"` : ''}>${csrfField(req)}${inner}</form>`;