openvibers/openvibe.games Commits
Agent layer: /.well-known/agent.json and Markdown twins of the portal and the game (#200)
Changes
@@@ -24,7 +24,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0","quickjs-emscripten": "0.32.0","tsx": "^4.19.3",nnew file mode 100644@@@ -0,0 +1,192 @@++import { existsSync, readFileSync, statSync } from 'node:fs'++import { join } from 'node:path'++import { createRequire } from 'node:module'++import type { IncomingMessage, ServerResponse } from 'node:http'++import { casualGameOf, casualPageHtml } from '../casual/page.js'++++/**++ * The agent layer (openvibe-shared/agent-layer) for the game server. The server is raw node:http, not++ * Express, so this uses the same builders the Express mount uses — agentManifest, markdownTwin and++ * linkHeaderTags — instead of app.use. /.well-known/agent.json and the Markdown twins of the portal, the++ * game and the world pages are answered here; the HTML pages themselves get the same discovery <link>++ * tags and Link header as they are served (addAgentHead, agentLinkFor).++ */++const require = createRequire(import.meta.url)++const agent = require('openvibe-shared/agent-layer') as {++ agentManifest: (descriptor: Record<string, unknown>) => unknown++ markdownTwin: (o: {++ html: string++ title?: string++ canonical: string++ updated?: string | null++ }) => string++ linkHeaderTags: (canonical: string, llms?: string) => { head: string; header: string }++}++++const SITE = 'https://openvibe.games'++const PLAY_SITE = 'https://play.openvibe.games'++const LLMS = `${SITE}/llms.txt`++++/** What openvibe.games tells an agent about itself. Every field is something this site really serves. */++export const AGENT_DESCRIPTOR: Record<string, unknown> = {++ id: 'games',++ name: 'OpenVibe.Games',++ purpose:++ 'Browser games on the OpenVibe network: the games portal, Scraplandia (a persistent multiplayer survival-building world), Salvage Rush, Vibe Heroes and the worlds players create.',++ home: SITE,++ site: 'openvibe.games',++ api: '/api',++ policy: {++ auth: ['none', 'Network OIDC'],++ rateLimit:++ 'The public pages are not rate-limited; the editor, map-asset and websocket-ticket routes are limited per address and per signed-in actor',++ },++}++++/** The public pages that have a Markdown twin: the portal, the game and the world pages. */++const STATIC_PAGES: Record<string, { file: string; canonical: string }> = {++ '/': { file: 'index.html', canonical: `${SITE}/` },++ '/play': { file: 'play.html', canonical: `${PLAY_SITE}/` },++ '/worlds': { file: 'worlds.html', canonical: `${SITE}/worlds` },++}++++/** The client build's own files that are pages with a twin (the portal is served through portalPage). */++export const AGENT_PAGE_FILES = new Set(['index.html', 'play.html', 'worlds.html', 'casual.html'])++++/** What the twin renderer needs from the server: the built client, the portal's live parts and the host. */++export interface AgentContext {++ root: string | null++ /** The request's host (the play host serves the game at its root). */++ host: string++ /** The portal page as the HTML route renders it (with its live stats and sections), for the twin. */++ portalHtml?: () => Promise<string | null>++}++++/** The Link header a page with a twin sends. */++export function agentLinkFor(canonical: string): string {++ return agent.linkHeaderTags(canonical, LLMS).header++}++++/** Add the discovery <link> tags to a page's head (the page already links its own canonical). */++export function addAgentHead(html: string, canonical: string): string {++ if (/rel="alternate" type="text\/markdown"/.test(html)) return html++ const tags = agent.linkHeaderTags(canonical, LLMS).head++ return html.includes('</head>') ? html.replace('</head>', `${tags}\n</head>`) : html++}++++/** The canonical a page names for itself, when it names one. */++export function canonicalOf(html: string): string | null {++ const m = /<link[^>]+rel="canonical"[^>]+href="([^"]+)"/i.exec(html)++ return m ? m[1]! : null++}++++// The built client is read once per file version: the game's event loop never reads a file per request.++const memo = new Map<string, { path: string; mtimeMs: number; html: string }>()++export function readPageFile(filePath: string): string | null {++ try {++ const mtimeMs = statSync(filePath).mtimeMs++ const hit = memo.get(filePath)++ if (hit && hit.path === filePath && hit.mtimeMs === mtimeMs) return hit.html++ const html = readFileSync(filePath, 'utf8')++ memo.set(filePath, { path: filePath, mtimeMs, html })++ return html++ } catch {++ return null++ }++}++++/** The page a path names, as the HTML route renders it: { html, canonical } or null when it is not public. */++export async function agentPageFor(++ path: string,++ ctx: AgentContext,++): Promise<{ html: string; canonical: string } | null> {++ const { root } = ctx++ if (!root) return null++ const clean = String(path || '/').replace(/\/+$/, '') || '/'++ // On the play host the game is the root page (the apex root is the games portal).++ if (clean === '/' && ctx.host.toLowerCase().startsWith('play.')) {++ const html = readPageFile(join(root, 'play.html'))++ return html ? { html, canonical: `${PLAY_SITE}/` } : null++ }++ // A casual game is its own page (the build's casual.html with that game's own head and copy).++ const casual = casualGameOf(clean)++ if (casual) {++ const file = join(root, 'casual.html')++ if (!existsSync(file)) return null++ return { html: casualPageHtml(file, casual), canonical: `${SITE}/play/${casual}` }++ }++ if (clean === '/') {++ const portal = ctx.portalHtml ? await ctx.portalHtml() : null++ if (!portal) return null++ return { html: portal, canonical: `${SITE}/` }++ }++ const spec = STATIC_PAGES[clean]++ if (!spec) return null++ const html = readPageFile(join(root, spec.file))++ return html ? { html, canonical: spec.canonical } : null++}++++function send(++ res: ServerResponse,++ status: number,++ type: string,++ body: string,++ canonical?: string,++ method?: string,++): void {++ res.writeHead(status, {++ 'content-type': type,++ 'cache-control': status === 200 ? 'public, max-age=300' : 'public, max-age=60',++ ...(canonical ? { link: agentLinkFor(canonical) } : {}),++ vary: 'Accept',++ })++ res.end(method === 'HEAD' ? undefined : body)++}++++/** True when this request is the agent manifest or a Markdown twin: the agent layer answers it. */++export function isAgentRequest(req: IncomingMessage): boolean {++ const method = String(req.method ?? 'GET')++ if (!['GET', 'HEAD'].includes(method)) return false++ const url = new URL(req.url ?? '/', SITE)++ if (url.pathname === '/.well-known/agent.json') return true++ return (++ url.pathname.endsWith('.md') ||++ url.searchParams.get('format') === 'markdown' ||++ /\btext\/markdown\b/.test(String(req.headers.accept ?? ''))++ )++}++++/** True when this request was the agent manifest or a Markdown twin (and has been answered). */++export async function handleAgent(++ req: IncomingMessage,++ res: ServerResponse,++ ctx: AgentContext,++): Promise<boolean> {++ const method = String(req.method ?? 'GET')++ if (!['GET', 'HEAD'].includes(method)) return false++ const url = new URL(req.url ?? '/', SITE)++ if (url.pathname === '/.well-known/agent.json') {++ res.writeHead(200, {++ 'content-type': 'application/json; charset=utf-8',++ 'cache-control': 'public, max-age=3600',++ })++ res.end(method === 'HEAD' ? undefined : JSON.stringify(agent.agentManifest(AGENT_DESCRIPTOR)))++ return true++ }++ const accept = String(req.headers.accept ?? '')++ const wantsMarkdown =++ url.pathname.endsWith('.md') ||++ url.searchParams.get('format') === 'markdown' ||++ /\btext\/markdown\b/.test(accept)++ if (!wantsMarkdown) return false++ let path = url.pathname === '/index.md' ? '/' : url.pathname.endsWith('.md') ? url.pathname.slice(0, -3) : url.pathname++ path = path.replace(/\/+$/, '') || '/'++ const page = await agentPageFor(path, ctx)++ if (!page) {++ send(res, 404, 'text/markdown; charset=utf-8', '# Not found\n', undefined, method)++ return true++ }++ const body = agent.markdownTwin({ html: page.html, canonical: page.canonical })++ send(res, 200, 'text/markdown; charset=utf-8', body, page.canonical, method)++ return true++}@@@ -388,7 +388,7 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolsend(res,'text/plain',-- seo.robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`], disallow: DISALLOW }),++ seo.robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`], disallow: DISALLOW }) + `# Agent manifest: ${SITE}/.well-known/agent.json\n`,)return true}@@@ -78,8 +78,9 @@ beforeAll(async () => {dir = mkdtempSync(join(tmpdir(), 'ovg-http-'))const dist = join(dir, 'dist')mkdirSync(join(dist, 'assets', 'tex'), { recursive: true })-- writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')-- writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')++ // A real page has a head to carry the discovery tags into (the agent layer adds them there).++ writeFileSync(join(dist, 'index.html'), '<!doctype html><html><head><title>portal page</title></head><body><h1>portal page</h1></body></html>')++ writeFileSync(join(dist, 'play.html'), '<!doctype html><html><head><title>game page</title></head><body><h1>game page</h1></body></html>')writeFileSync(join(dist, 'editor.html'), '<!doctype html><title>editor page</title>')writeFileSync(join(dist, 'frame.js'), 'void 0')writeFileSync(join(dist, ASSET.slice(1)), 'export {}')@@@ -384,3 +385,58 @@ describe('/api/ws-ticket is rate limited per address', () => {}})})++++describe('the agent layer', () => {++ it('serves the manifest on the apex and on the play host', async () => {++ for (const host of [APEX, PLAY]) {++ const r = await send('/.well-known/agent.json', { host })++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('application/json; charset=utf-8')++ const d = JSON.parse(r.body)++ expect(d).toMatchObject({ id: 'games', site: 'openvibe.games', home: 'https://openvibe.games' })++ expect(d.api).toBe('https://openvibe.games/api')++ expect(d.policy.auth).toEqual(['none', 'Network OIDC'])++ }++ })++++ it('twins the portal, the game and a world page', async () => {++ const index = await send('/index.md')++ expect(index.status).toBe(200)++ expect(index.headers['content-type']).toBe('text/markdown; charset=utf-8')++ expect(index.headers.link).toContain('rel="alternate"')++ expect(index.body).toContain('canonical: "https://openvibe.games/"')++ expect(index.body).toContain('# portal page')++ // The game's canonical home is the play host's root (play.html names it), so its twin does too,++ // wherever the twin is asked for.++ const play = await send('/play.md')++ expect(play.status).toBe(200)++ expect(play.body).toContain('canonical: "https://play.openvibe.games/"')++ expect(play.body).toContain('# game page')++ // The play host's root IS the game.++ const root = await send('/index.md', { host: PLAY })++ expect(root.status).toBe(200)++ expect(root.body).toContain('canonical: "https://play.openvibe.games/"')++ expect(root.body).toContain('# game page')++ })++++ it('names the twin on the HTML page, and answers a markdown preference', async () => {++ // The portal names its twin: the stub pages carry no canonical, so the apex root is the fallback.++ const page = await send('/')++ expect(page.headers.link).toContain('<https://openvibe.games/index.md>; rel="alternate"; type="text/markdown"')++ expect(page.body).toContain('<link rel="alternate" type="text/markdown" href="https://openvibe.games/index.md">')++ expect(page.body).toContain('<link rel="describedby" href="https://openvibe.games/llms.txt">')++ const md = await send('/play', { headers: { accept: 'text/markdown' } })++ expect(md.headers['content-type']).toBe('text/markdown; charset=utf-8')++ expect(md.body).toContain('# game page')++ // A browser's request is the page itself, never the twin.++ const browser = await send('/play', { headers: { accept: 'text/html,application/xhtml+xml' } })++ expect(browser.headers['content-type']).toBe('text/html; charset=utf-8')++ })++++ it('has no twin for a page that is not public', async () => {++ const r = await send('/nothing.md')++ expect(r.status).toBe(404)++ expect(r.headers['content-type']).toBe('text/markdown; charset=utf-8')++ expect(r.body).toBe('# Not found\n')++ })++})@@@ -1,7 +1,7 @@import { randomBytes } from 'node:crypto'import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'--import { dirname, extname, join, normalize, resolve } from 'node:path'++import { dirname, extname, join, normalize, resolve, sep } from 'node:path'import type { Logger } from '@openvibe/shared'import type { ServerMetrics } from '../observability/metrics.js'import { isDirectLoopback, prometheusText, wantsPrometheus } from '../observability/release.js'@@@ -30,6 +30,16 @@ import type { GamesActorLimits } from './actorLimits.js'import { resolveClientAddress } from './clientAddress.js'import { sendNotFound, type NotFoundLink } from './notFound.js'import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'++import {++ addAgentHead,++ agentLinkFor,++ canonicalOf,++ handleAgent,++ isAgentRequest,++ readPageFile,++ AGENT_PAGE_FILES,++ type AgentContext,++} from './agent.js'import type { HomeParts } from './portalHome.js'import { legalHandler } from './legalPages.js'import { updatesHandler } from './updatesPage.js'@@@ -243,6 +253,20 @@ export function createHttpServer(mapPathFor?: (place: string | undefined) => string | undefined,): Server {const root = staticDir ? resolve(staticDir) : null++ // The agent layer (openvibe-shared/agent-layer): the Markdown twins of the public pages render through the++ // same builders the HTML routes use, so a twin cannot drift from its page.++ const agentCtx: AgentContext = {++ root,++ host: '',++ portalHtml: async () => {++ if (!root) return null++ const file = join(root, 'index.html')++ if (!existsSync(file)) return null++ const live = platform?.homeSections++ const parts = live ? await live().catch(() => null) : null++ return portalPage(file, parts)++ },++ }// Sessions the Network confirmed recently: lets a silent login on a signed-in// browser answer without the Network round trip. See /auth/login.const knownSessions = new SessionCheckCache()@@@ -798,6 +822,20 @@ export function createHttpServer(notFound()return}++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the++ // portal, the game and the world pages. Before the page routes, so a twin is the page's own render and++ // never a static file; after the API routes, so /api keeps answering for itself.++ if (isAgentRequest(req)) {++ const ctx: AgentContext = { ...agentCtx, host: String(req.headers.host ?? '') }++ void handleAgent(req, res, ctx).catch((err: unknown) => {++ log.warn('agent layer failed', { error: String(err) })++ if (!res.headersSent) {++ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })++ res.end('Something went wrong on our side. Try again in a moment.')++ }++ })++ return++ }const surfRoute = surfPageRoute(url, new URL(req.url ?? '/', 'http://games.local').searchParams)if (surfRoute?.redirect) {res.writeHead(301, { location: surfRoute.redirect, 'cache-control': 'no-store' })@@@ -893,16 +931,42 @@ export function createHttpServer(if (pageAlias === 'index.html' || url === '/index.html') {const live = platform?.homeSectionsvoid (live ? live().catch(() => null) : Promise.resolve(null)).then((parts) => {-- const body = portalPage(filePath, parts)++ const page = portalPage(filePath, parts)++ // The portal has a Markdown twin, so the page names it (the agent layer's discovery tags).++ const canonical = canonicalOf(page) ?? `${DEFAULT_PORTAL_URL.replace(/\/+$/, '')}/`++ const body = addAgentHead(page, canonical)res.writeHead(200, {'content-type': 'text/html; charset=utf-8','cache-control': 'no-cache','content-length': String(Buffer.byteLength(body)),++ link: agentLinkFor(canonical),})res.end(req.method === 'HEAD' ? undefined : body)})return}++ // The other pages with a twin (the game, the world list, a casual game) carry the same discovery tags++ // and the Link header as they are served.++ const fileName = filePath.slice(filePath.lastIndexOf(sep) + 1)++ if (AGENT_PAGE_FILES.has(fileName)) {++ const page = readPageFile(filePath)++ if (page) {++ const canonical = canonicalOf(page) ?? `${DEFAULT_PORTAL_URL.replace(/\/+$/, '')}/`++ const body = addAgentHead(page, canonical)++ res.writeHead(200, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'no-cache',++ 'content-length': String(Buffer.byteLength(body)),++ link: agentLinkFor(canonical),++ // The embeddable surf route keeps its frame rule on this path too (it is play.html).++ ...(url === '/surf/embed'++ ? { 'content-security-policy': "frame-ancestors https://openvibe.surf 'self'" }++ : {}),++ })++ res.end(req.method === 'HEAD' ? undefined : body)++ return++ }++ }const type = MIME[extname(filePath)] ?? 'application/octet-stream'res.writeHead(200, {'content-type': type,@@@ -128,13 +128,13 @@ importers:version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0openvibe-publishing:specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0-- version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0)++ version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0)openvibe-sdk:specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])openvibe-shared:-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0-- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0++ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0pg:specifier: ^8.23.0version: 8.23.0@@@ -223,6 +223,9 @@ importers:packages:++ '@acemir/[email protected]':++ resolution: {integrity: sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==}++'@asamuzakjp/[email protected]':resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}engines: {node: ^22.13.0 || >=24.0.0}@@@ -992,6 +995,10 @@ packages:resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}++ resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==}++ engines: {node: '>=20.19.0'}++resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}@@@ -1035,10 +1042,18 @@ packages:resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}engines: {node: '>= 8'}++ resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==}++ engines: {node: '>=20.19.0'}++resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}++ resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==}++ engines: {node: '>=20.19.0'}++resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}@@@ -1062,9 +1077,46 @@ packages:resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}engines: {node: '>=0.10'}++ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}++++ resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}++ engines: {node: '>=20.19.0'}++++ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}++++ resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}++ engines: {node: '>=20.19.0'}++++ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}++ engines: {node: '>= 4'}++++ resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}++ engines: {node: '>=20.19.0'}++++ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}++++ resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}++ engines: {node: '>=20.19.0'}++resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}++ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}++ engines: {node: '>=0.12'}++++ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}++ engines: {node: '>=0.12'}++resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}engines: {node: '>=20.19.0'}@@@ -1202,6 +1254,12 @@ packages:resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}++ resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}++++ resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}++resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}engines: {node: '>= 4'}@@@ -1278,6 +1336,15 @@ packages:resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}engines: {node: '>= 0.8.0'}++ resolution: {integrity: sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==}++ engines: {node: '>=16'}++ peerDependencies:++ canvas: '>= 2'++ peerDependenciesMeta:++ canvas:++ optional: true++resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}engines: {node: '>=10'}@@@ -1340,6 +1407,10 @@ packages:resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}++ resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==}++ engines: {node: '>=20.19.0'}++resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}engines: {node: '>=12.20.0'}@@@ -1378,9 +1449,9 @@ packages:pg:optional: true-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0:-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0}-- version: 4.33.0++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0:++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0}++ version: 4.34.0hasBin: true@@@ -1633,6 +1704,9 @@ packages:engines: {node: '>=14.17'}hasBin: true++ resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}++resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}@@@ -1794,6 +1868,8 @@ packages:snapshots:++ '@acemir/[email protected]': {}++'@asamuzakjp/[email protected]':dependencies:'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])@@@ -2377,6 +2453,8 @@ snapshots:dependencies:require-from-string: 2.0.2++ [email protected]: {}++dependencies:balanced-match: 1.0.2@@@ -2415,11 +2493,21 @@ snapshots:shebang-command: 2.0.0which: 2.0.2++ dependencies:++ boolbase: 2.0.0++ css-what: 8.0.0++ domhandler: 6.0.1++ domutils: 4.0.2++ nth-check: 3.0.1++dependencies:mdn-data: 2.27.1source-map-js: 1.2.1++ [email protected]: {}++dependencies:whatwg-mimetype: 5.0.0@@@ -2437,10 +2525,50 @@ snapshots:++ dependencies:++ domelementtype: 2.3.0++ domhandler: 5.0.3++ entities: 4.5.0++++ dependencies:++ domelementtype: 3.0.0++ domhandler: 6.0.1++ entities: 8.0.0++++ [email protected]: {}++++ [email protected]: {}++++ dependencies:++ domelementtype: 2.3.0++++ dependencies:++ domelementtype: 3.0.0++++ dependencies:++ dom-serializer: 2.0.0++ domelementtype: 2.3.0++ domhandler: 5.0.3++++ dependencies:++ dom-serializer: 3.1.1++ domelementtype: 3.0.0++ domhandler: 6.0.1++dependencies:safe-buffer: 5.2.1++ [email protected]: {}++++ [email protected]: {}++@@@ -2627,6 +2755,15 @@ snapshots:transitivePeerDependencies:- '@noble/hashes'++ [email protected]: {}++++ dependencies:++ domelementtype: 2.3.0++ domhandler: 5.0.3++ domutils: 3.2.2++ entities: 7.0.1++@@@ -2733,6 +2870,14 @@ snapshots:prelude-ls: 1.2.1type-check: 0.4.0++ dependencies:++ '@acemir/cssom': 0.9.32++ css-select: 7.0.0++ html-escaper: 3.0.3++ htmlparser2: 10.1.0++ uhyphen: 0.2.0++dependencies:p-locate: 5.0.0@@@ -2779,6 +2924,10 @@ snapshots:++ dependencies:++ boolbase: 2.0.0++openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0:@@@ -2786,11 +2935,11 @@ snapshots:ajv: 8.20.0ajv-formats: 3.0.1([email protected])-- openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0):++ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0):dependencies:openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])optionalDependencies:-- openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0++ openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]):optionalDependencies:@@@ -2798,9 +2947,12 @@ snapshots:iovalkey: 0.4.0pg: 8.23.0-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0:++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0:dependencies:jsonwebtoken: 9.0.3++ linkedom: 0.18.14++ transitivePeerDependencies:++ - canvasdependencies:@@@ -3042,6 +3194,8 @@ snapshots:++ [email protected]: {}++
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.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",
"quickjs-emscripten": "0.32.0",
"quickjs-emscripten": "0.32.0",
"tsx": "^4.19.3",
"tsx": "^4.19.3",
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+import { existsSync, readFileSync, statSync } from 'node:fs'
+import { join } from 'node:path'
+import { createRequire } from 'node:module'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { casualGameOf, casualPageHtml } from '../casual/page.js'
+
+/**
+ * The agent layer (openvibe-shared/agent-layer) for the game server. The server is raw node:http, not
+ * Express, so this uses the same builders the Express mount uses — agentManifest, markdownTwin and
+ * linkHeaderTags — instead of app.use. /.well-known/agent.json and the Markdown twins of the portal, the
+ * game and the world pages are answered here; the HTML pages themselves get the same discovery <link>
+ * tags and Link header as they are served (addAgentHead, agentLinkFor).
+ */
+const require = createRequire(import.meta.url)
+const agent = require('openvibe-shared/agent-layer') as {
+ agentManifest: (descriptor: Record<string, unknown>) => unknown
+ markdownTwin: (o: {
+ html: string
+ title?: string
+ canonical: string
+ updated?: string | null
+ }) => string
+ linkHeaderTags: (canonical: string, llms?: string) => { head: string; header: string }
+}
+
+const SITE = 'https://openvibe.games'
+const PLAY_SITE = 'https://play.openvibe.games'
+const LLMS = `${SITE}/llms.txt`
+
+/** What openvibe.games tells an agent about itself. Every field is something this site really serves. */
+export const AGENT_DESCRIPTOR: Record<string, unknown> = {
+ id: 'games',
+ name: 'OpenVibe.Games',
+ purpose:
+ 'Browser games on the OpenVibe network: the games portal, Scraplandia (a persistent multiplayer survival-building world), Salvage Rush, Vibe Heroes and the worlds players create.',
+ home: SITE,
+ site: 'openvibe.games',
+ api: '/api',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ rateLimit:
+ 'The public pages are not rate-limited; the editor, map-asset and websocket-ticket routes are limited per address and per signed-in actor',
+ },
+}
+
+/** The public pages that have a Markdown twin: the portal, the game and the world pages. */
+const STATIC_PAGES: Record<string, { file: string; canonical: string }> = {
+ '/': { file: 'index.html', canonical: `${SITE}/` },
+ '/play': { file: 'play.html', canonical: `${PLAY_SITE}/` },
+ '/worlds': { file: 'worlds.html', canonical: `${SITE}/worlds` },
+}
+
+/** The client build's own files that are pages with a twin (the portal is served through portalPage). */
+export const AGENT_PAGE_FILES = new Set(['index.html', 'play.html', 'worlds.html', 'casual.html'])
+
+/** What the twin renderer needs from the server: the built client, the portal's live parts and the host. */
+export interface AgentContext {
+ root: string | null
+ /** The request's host (the play host serves the game at its root). */
+ host: string
+ /** The portal page as the HTML route renders it (with its live stats and sections), for the twin. */
+ portalHtml?: () => Promise<string | null>
+}
+
+/** The Link header a page with a twin sends. */
+export function agentLinkFor(canonical: string): string {
+ return agent.linkHeaderTags(canonical, LLMS).header
+}
+
+/** Add the discovery <link> tags to a page's head (the page already links its own canonical). */
+export function addAgentHead(html: string, canonical: string): string {
+ if (/rel="alternate" type="text\/markdown"/.test(html)) return html
+ const tags = agent.linkHeaderTags(canonical, LLMS).head
+ return html.includes('</head>') ? html.replace('</head>', `${tags}\n</head>`) : html
+}
+
+/** The canonical a page names for itself, when it names one. */
+export function canonicalOf(html: string): string | null {
+ const m = /<link[^>]+rel="canonical"[^>]+href="([^"]+)"/i.exec(html)
+ return m ? m[1]! : null
+}
+
+// The built client is read once per file version: the game's event loop never reads a file per request.
+const memo = new Map<string, { path: string; mtimeMs: number; html: string }>()
+export function readPageFile(filePath: string): string | null {
+ try {
+ const mtimeMs = statSync(filePath).mtimeMs
+ const hit = memo.get(filePath)
+ if (hit && hit.path === filePath && hit.mtimeMs === mtimeMs) return hit.html
+ const html = readFileSync(filePath, 'utf8')
+ memo.set(filePath, { path: filePath, mtimeMs, html })
+ return html
+ } catch {
+ return null
+ }
+}
+
+/** The page a path names, as the HTML route renders it: { html, canonical } or null when it is not public. */
+export async function agentPageFor(
+ path: string,
+ ctx: AgentContext,
+): Promise<{ html: string; canonical: string } | null> {
+ const { root } = ctx
+ if (!root) return null
+ const clean = String(path || '/').replace(/\/+$/, '') || '/'
+ // On the play host the game is the root page (the apex root is the games portal).
+ if (clean === '/' && ctx.host.toLowerCase().startsWith('play.')) {
+ const html = readPageFile(join(root, 'play.html'))
+ return html ? { html, canonical: `${PLAY_SITE}/` } : null
+ }
+ // A casual game is its own page (the build's casual.html with that game's own head and copy).
+ const casual = casualGameOf(clean)
+ if (casual) {
+ const file = join(root, 'casual.html')
+ if (!existsSync(file)) return null
+ return { html: casualPageHtml(file, casual), canonical: `${SITE}/play/${casual}` }
+ }
+ if (clean === '/') {
+ const portal = ctx.portalHtml ? await ctx.portalHtml() : null
+ if (!portal) return null
+ return { html: portal, canonical: `${SITE}/` }
+ }
+ const spec = STATIC_PAGES[clean]
+ if (!spec) return null
+ const html = readPageFile(join(root, spec.file))
+ return html ? { html, canonical: spec.canonical } : null
+}
+
+function send(
+ res: ServerResponse,
+ status: number,
+ type: string,
+ body: string,
+ canonical?: string,
+ method?: string,
+): void {
+ res.writeHead(status, {
+ 'content-type': type,
+ 'cache-control': status === 200 ? 'public, max-age=300' : 'public, max-age=60',
+ ...(canonical ? { link: agentLinkFor(canonical) } : {}),
+ vary: 'Accept',
+ })
+ res.end(method === 'HEAD' ? undefined : body)
+}
+
+/** True when this request is the agent manifest or a Markdown twin: the agent layer answers it. */
+export function isAgentRequest(req: IncomingMessage): boolean {
+ const method = String(req.method ?? 'GET')
+ if (!['GET', 'HEAD'].includes(method)) return false
+ const url = new URL(req.url ?? '/', SITE)
+ if (url.pathname === '/.well-known/agent.json') return true
+ return (
+ url.pathname.endsWith('.md') ||
+ url.searchParams.get('format') === 'markdown' ||
+ /\btext\/markdown\b/.test(String(req.headers.accept ?? ''))
+ )
+}
+
+/** True when this request was the agent manifest or a Markdown twin (and has been answered). */
+export async function handleAgent(
+ req: IncomingMessage,
+ res: ServerResponse,
+ ctx: AgentContext,
+): Promise<boolean> {
+ const method = String(req.method ?? 'GET')
+ if (!['GET', 'HEAD'].includes(method)) return false
+ const url = new URL(req.url ?? '/', SITE)
+ if (url.pathname === '/.well-known/agent.json') {
+ res.writeHead(200, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'public, max-age=3600',
+ })
+ res.end(method === 'HEAD' ? undefined : JSON.stringify(agent.agentManifest(AGENT_DESCRIPTOR)))
+ return true
+ }
+ const accept = String(req.headers.accept ?? '')
+ const wantsMarkdown =
+ url.pathname.endsWith('.md') ||
+ url.searchParams.get('format') === 'markdown' ||
+ /\btext\/markdown\b/.test(accept)
+ if (!wantsMarkdown) return false
+ let path = url.pathname === '/index.md' ? '/' : url.pathname.endsWith('.md') ? url.pathname.slice(0, -3) : url.pathname
+ path = path.replace(/\/+$/, '') || '/'
+ const page = await agentPageFor(path, ctx)
+ if (!page) {
+ send(res, 404, 'text/markdown; charset=utf-8', '# Not found\n', undefined, method)
+ return true
+ }
+ const body = agent.markdownTwin({ html: page.html, canonical: page.canonical })
+ send(res, 200, 'text/markdown; charset=utf-8', body, page.canonical, method)
+ return true
+}
@@ -388,7 +388,7 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
@@ -388,7 +388,7 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
send(
send(
res,
res,
'text/plain',
'text/plain',
- seo.robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`], disallow: DISALLOW }),
+ seo.robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`], disallow: DISALLOW }) + `# Agent manifest: ${SITE}/.well-known/agent.json\n`,
)
)
return true
return true
}
}
@@ -78,8 +78,9 @@ beforeAll(async () => {
@@ -78,8 +78,9 @@ beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'ovg-http-'))
dir = mkdtempSync(join(tmpdir(), 'ovg-http-'))
const dist = join(dir, 'dist')
const dist = join(dir, 'dist')
mkdirSync(join(dist, 'assets', 'tex'), { recursive: true })
mkdirSync(join(dist, 'assets', 'tex'), { recursive: true })
- writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')
- writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')
+ // A real page has a head to carry the discovery tags into (the agent layer adds them there).
+ writeFileSync(join(dist, 'index.html'), '<!doctype html><html><head><title>portal page</title></head><body><h1>portal page</h1></body></html>')
+ writeFileSync(join(dist, 'play.html'), '<!doctype html><html><head><title>game page</title></head><body><h1>game page</h1></body></html>')
writeFileSync(join(dist, 'editor.html'), '<!doctype html><title>editor page</title>')
writeFileSync(join(dist, 'editor.html'), '<!doctype html><title>editor page</title>')
writeFileSync(join(dist, 'frame.js'), 'void 0')
writeFileSync(join(dist, 'frame.js'), 'void 0')
writeFileSync(join(dist, ASSET.slice(1)), 'export {}')
writeFileSync(join(dist, ASSET.slice(1)), 'export {}')
@@ -384,3 +385,58 @@ describe('/api/ws-ticket is rate limited per address', () => {
@@ -384,3 +385,58 @@ describe('/api/ws-ticket is rate limited per address', () => {
}
}
})
})
})
})
+
+describe('the agent layer', () => {
+ it('serves the manifest on the apex and on the play host', async () => {
+ for (const host of [APEX, PLAY]) {
+ const r = await send('/.well-known/agent.json', { host })
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('application/json; charset=utf-8')
+ const d = JSON.parse(r.body)
+ expect(d).toMatchObject({ id: 'games', site: 'openvibe.games', home: 'https://openvibe.games' })
+ expect(d.api).toBe('https://openvibe.games/api')
+ expect(d.policy.auth).toEqual(['none', 'Network OIDC'])
+ }
+ })
+
+ it('twins the portal, the game and a world page', async () => {
+ const index = await send('/index.md')
+ expect(index.status).toBe(200)
+ expect(index.headers['content-type']).toBe('text/markdown; charset=utf-8')
+ expect(index.headers.link).toContain('rel="alternate"')
+ expect(index.body).toContain('canonical: "https://openvibe.games/"')
+ expect(index.body).toContain('# portal page')
+ // The game's canonical home is the play host's root (play.html names it), so its twin does too,
+ // wherever the twin is asked for.
+ const play = await send('/play.md')
+ expect(play.status).toBe(200)
+ expect(play.body).toContain('canonical: "https://play.openvibe.games/"')
+ expect(play.body).toContain('# game page')
+ // The play host's root IS the game.
+ const root = await send('/index.md', { host: PLAY })
+ expect(root.status).toBe(200)
+ expect(root.body).toContain('canonical: "https://play.openvibe.games/"')
+ expect(root.body).toContain('# game page')
+ })
+
+ it('names the twin on the HTML page, and answers a markdown preference', async () => {
+ // The portal names its twin: the stub pages carry no canonical, so the apex root is the fallback.
+ const page = await send('/')
+ expect(page.headers.link).toContain('<https://openvibe.games/index.md>; rel="alternate"; type="text/markdown"')
+ expect(page.body).toContain('<link rel="alternate" type="text/markdown" href="https://openvibe.games/index.md">')
+ expect(page.body).toContain('<link rel="describedby" href="https://openvibe.games/llms.txt">')
+ const md = await send('/play', { headers: { accept: 'text/markdown' } })
+ expect(md.headers['content-type']).toBe('text/markdown; charset=utf-8')
+ expect(md.body).toContain('# game page')
+ // A browser's request is the page itself, never the twin.
+ const browser = await send('/play', { headers: { accept: 'text/html,application/xhtml+xml' } })
+ expect(browser.headers['content-type']).toBe('text/html; charset=utf-8')
+ })
+
+ it('has no twin for a page that is not public', async () => {
+ const r = await send('/nothing.md')
+ expect(r.status).toBe(404)
+ expect(r.headers['content-type']).toBe('text/markdown; charset=utf-8')
+ expect(r.body).toBe('# Not found\n')
+ })
+})
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
import { randomBytes } from 'node:crypto'
import { randomBytes } from 'node:crypto'
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
-import { dirname, extname, join, normalize, resolve } from 'node:path'
+import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
import type { ServerMetrics } from '../observability/metrics.js'
import { isDirectLoopback, prometheusText, wantsPrometheus } from '../observability/release.js'
import { isDirectLoopback, prometheusText, wantsPrometheus } from '../observability/release.js'
@@ -30,6 +30,16 @@ import type { GamesActorLimits } from './actorLimits.js'
@@ -30,6 +30,16 @@ import type { GamesActorLimits } from './actorLimits.js'
import { resolveClientAddress } from './clientAddress.js'
import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'
import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'
+import {
+ addAgentHead,
+ agentLinkFor,
+ canonicalOf,
+ handleAgent,
+ isAgentRequest,
+ readPageFile,
+ AGENT_PAGE_FILES,
+ type AgentContext,
+} from './agent.js'
import type { HomeParts } from './portalHome.js'
import type { HomeParts } from './portalHome.js'
import { legalHandler } from './legalPages.js'
import { legalHandler } from './legalPages.js'
import { updatesHandler } from './updatesPage.js'
import { updatesHandler } from './updatesPage.js'
@@ -243,6 +253,20 @@ export function createHttpServer(
@@ -243,6 +253,20 @@ export function createHttpServer(
mapPathFor?: (place: string | undefined) => string | undefined,
mapPathFor?: (place: string | undefined) => string | undefined,
): Server {
): Server {
const root = staticDir ? resolve(staticDir) : null
const root = staticDir ? resolve(staticDir) : null
+ // The agent layer (openvibe-shared/agent-layer): the Markdown twins of the public pages render through the
+ // same builders the HTML routes use, so a twin cannot drift from its page.
+ const agentCtx: AgentContext = {
+ root,
+ host: '',
+ portalHtml: async () => {
+ if (!root) return null
+ const file = join(root, 'index.html')
+ if (!existsSync(file)) return null
+ const live = platform?.homeSections
+ const parts = live ? await live().catch(() => null) : null
+ return portalPage(file, parts)
+ },
+ }
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// browser answer without the Network round trip. See /auth/login.
// browser answer without the Network round trip. See /auth/login.
const knownSessions = new SessionCheckCache()
const knownSessions = new SessionCheckCache()
@@ -798,6 +822,20 @@ export function createHttpServer(
@@ -798,6 +822,20 @@ export function createHttpServer(
notFound()
notFound()
return
return
}
}
+ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
+ // portal, the game and the world pages. Before the page routes, so a twin is the page's own render and
+ // never a static file; after the API routes, so /api keeps answering for itself.
+ if (isAgentRequest(req)) {
+ const ctx: AgentContext = { ...agentCtx, host: String(req.headers.host ?? '') }
+ void handleAgent(req, res, ctx).catch((err: unknown) => {
+ log.warn('agent layer failed', { error: String(err) })
+ if (!res.headersSent) {
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
+ res.end('Something went wrong on our side. Try again in a moment.')
+ }
+ })
+ return
+ }
const surfRoute = surfPageRoute(url, new URL(req.url ?? '/', 'http://games.local').searchParams)
const surfRoute = surfPageRoute(url, new URL(req.url ?? '/', 'http://games.local').searchParams)
if (surfRoute?.redirect) {
if (surfRoute?.redirect) {
res.writeHead(301, { location: surfRoute.redirect, 'cache-control': 'no-store' })
res.writeHead(301, { location: surfRoute.redirect, 'cache-control': 'no-store' })
@@ -893,16 +931,42 @@ export function createHttpServer(
@@ -893,16 +931,42 @@ export function createHttpServer(
if (pageAlias === 'index.html' || url === '/index.html') {
if (pageAlias === 'index.html' || url === '/index.html') {
const live = platform?.homeSections
const live = platform?.homeSections
void (live ? live().catch(() => null) : Promise.resolve(null)).then((parts) => {
void (live ? live().catch(() => null) : Promise.resolve(null)).then((parts) => {
- const body = portalPage(filePath, parts)
+ const page = portalPage(filePath, parts)
+ // The portal has a Markdown twin, so the page names it (the agent layer's discovery tags).
+ const canonical = canonicalOf(page) ?? `${DEFAULT_PORTAL_URL.replace(/\/+$/, '')}/`
+ const body = addAgentHead(page, canonical)
res.writeHead(200, {
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-cache',
'cache-control': 'no-cache',
'content-length': String(Buffer.byteLength(body)),
'content-length': String(Buffer.byteLength(body)),
+ link: agentLinkFor(canonical),
})
})
res.end(req.method === 'HEAD' ? undefined : body)
res.end(req.method === 'HEAD' ? undefined : body)
})
})
return
return
}
}
+ // The other pages with a twin (the game, the world list, a casual game) carry the same discovery tags
+ // and the Link header as they are served.
+ const fileName = filePath.slice(filePath.lastIndexOf(sep) + 1)
+ if (AGENT_PAGE_FILES.has(fileName)) {
+ const page = readPageFile(filePath)
+ if (page) {
+ const canonical = canonicalOf(page) ?? `${DEFAULT_PORTAL_URL.replace(/\/+$/, '')}/`
+ const body = addAgentHead(page, canonical)
+ res.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-cache',
+ 'content-length': String(Buffer.byteLength(body)),
+ link: agentLinkFor(canonical),
+ // The embeddable surf route keeps its frame rule on this path too (it is play.html).
+ ...(url === '/surf/embed'
+ ? { 'content-security-policy': "frame-ancestors https://openvibe.surf 'self'" }
+ : {}),
+ })
+ res.end(req.method === 'HEAD' ? undefined : body)
+ return
+ }
+ }
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
res.writeHead(200, {
res.writeHead(200, {
'content-type': type,
'content-type': type,
@@ -128,13 +128,13 @@ importers:
@@ -128,13 +128,13 @@ importers:
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0
openvibe-publishing:
openvibe-publishing:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0
- version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0)
+ version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0)
openvibe-sdk:
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
openvibe-shared:
openvibe-shared:
- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0
+ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0
pg:
pg:
specifier: ^8.23.0
specifier: ^8.23.0
version: 8.23.0
version: 8.23.0
@@ -223,6 +223,9 @@ importers:
@@ -223,6 +223,9 @@ importers:
packages:
packages:
+ '@acemir/[email protected]':
+ resolution: {integrity: sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==}
+
'@asamuzakjp/[email protected]':
'@asamuzakjp/[email protected]':
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
engines: {node: ^22.13.0 || >=24.0.0}
engines: {node: ^22.13.0 || >=24.0.0}
@@ -992,6 +995,10 @@ packages:
@@ -992,6 +995,10 @@ packages:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+ resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==}
+ engines: {node: '>=20.19.0'}
+
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
@@ -1035,10 +1042,18 @@ packages:
@@ -1035,10 +1042,18 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
engines: {node: '>= 8'}
+ resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==}
+ engines: {node: '>=20.19.0'}
+
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+ resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==}
+ engines: {node: '>=20.19.0'}
+
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1062,9 +1077,46 @@ packages:
@@ -1062,9 +1077,46 @@ packages:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
engines: {node: '>=0.10'}
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+ resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
+ engines: {node: '>=20.19.0'}
+
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+ resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
+ engines: {node: '>=20.19.0'}
+
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+
+ resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
+ engines: {node: '>=20.19.0'}
+
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
+ resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
+ engines: {node: '>=20.19.0'}
+
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
engines: {node: '>=20.19.0'}
@@ -1202,6 +1254,12 @@ packages:
@@ -1202,6 +1254,12 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
+
+ resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
engines: {node: '>= 4'}
@@ -1278,6 +1336,15 @@ packages:
@@ -1278,6 +1336,15 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
engines: {node: '>= 0.8.0'}
+ resolution: {integrity: sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ canvas: '>= 2'
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
engines: {node: '>=10'}
@@ -1340,6 +1407,10 @@ packages:
@@ -1340,6 +1407,10 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==}
+ engines: {node: '>=20.19.0'}
+
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
engines: {node: '>=12.20.0'}
@@ -1378,9 +1449,9 @@ packages:
@@ -1378,9 +1449,9 @@ packages:
pg:
pg:
optional: true
optional: true
- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0:
- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0}
- version: 4.33.0
+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0:
+ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0}
+ version: 4.34.0
hasBin: true
hasBin: true
@@ -1633,6 +1704,9 @@ packages:
@@ -1633,6 +1704,9 @@ packages:
engines: {node: '>=14.17'}
engines: {node: '>=14.17'}
hasBin: true
hasBin: true
+ resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}
+
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -1794,6 +1868,8 @@ packages:
@@ -1794,6 +1868,8 @@ packages:
snapshots:
snapshots:
+ '@acemir/[email protected]': {}
+
'@asamuzakjp/[email protected]':
'@asamuzakjp/[email protected]':
dependencies:
dependencies:
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
@@ -2377,6 +2453,8 @@ snapshots:
@@ -2377,6 +2453,8 @@ snapshots:
dependencies:
dependencies:
require-from-string: 2.0.2
require-from-string: 2.0.2
+ [email protected]: {}
+
dependencies:
dependencies:
balanced-match: 1.0.2
balanced-match: 1.0.2
@@ -2415,11 +2493,21 @@ snapshots:
@@ -2415,11 +2493,21 @@ snapshots:
shebang-command: 2.0.0
shebang-command: 2.0.0
which: 2.0.2
which: 2.0.2
+ dependencies:
+ boolbase: 2.0.0
+ css-what: 8.0.0
+ domhandler: 6.0.1
+ domutils: 4.0.2
+ nth-check: 3.0.1
+
dependencies:
dependencies:
mdn-data: 2.27.1
mdn-data: 2.27.1
source-map-js: 1.2.1
source-map-js: 1.2.1
+ [email protected]: {}
+
dependencies:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-mimetype: 5.0.0
@@ -2437,10 +2525,50 @@ snapshots:
@@ -2437,10 +2525,50 @@ snapshots:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+ dependencies:
+ domelementtype: 3.0.0
+ domhandler: 6.0.1
+ entities: 8.0.0
+
+ [email protected]: {}
+
+ [email protected]: {}
+
+ dependencies:
+ domelementtype: 2.3.0
+
+ dependencies:
+ domelementtype: 3.0.0
+
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+
+ dependencies:
+ dom-serializer: 3.1.1
+ domelementtype: 3.0.0
+ domhandler: 6.0.1
+
dependencies:
dependencies:
safe-buffer: 5.2.1
safe-buffer: 5.2.1
+ [email protected]: {}
+
+ [email protected]: {}
+
@@ -2627,6 +2755,15 @@ snapshots:
@@ -2627,6 +2755,15 @@ snapshots:
transitivePeerDependencies:
transitivePeerDependencies:
- '@noble/hashes'
- '@noble/hashes'
+ [email protected]: {}
+
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ entities: 7.0.1
+
@@ -2733,6 +2870,14 @@ snapshots:
@@ -2733,6 +2870,14 @@ snapshots:
prelude-ls: 1.2.1
prelude-ls: 1.2.1
type-check: 0.4.0
type-check: 0.4.0
+ dependencies:
+ '@acemir/cssom': 0.9.32
+ css-select: 7.0.0
+ html-escaper: 3.0.3
+ htmlparser2: 10.1.0
+ uhyphen: 0.2.0
+
dependencies:
dependencies:
p-locate: 5.0.0
p-locate: 5.0.0
@@ -2779,6 +2924,10 @@ snapshots:
@@ -2779,6 +2924,10 @@ snapshots:
+ dependencies:
+ boolbase: 2.0.0
+
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:
@@ -2786,11 +2935,11 @@ snapshots:
@@ -2786,11 +2935,11 @@ snapshots:
ajv: 8.20.0
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
ajv-formats: 3.0.1([email protected])
- openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0):
+ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0):
dependencies:
dependencies:
openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
optionalDependencies:
optionalDependencies:
- openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
+ openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]):
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]):
optionalDependencies:
optionalDependencies:
@@ -2798,9 +2947,12 @@ snapshots:
@@ -2798,9 +2947,12 @@ snapshots:
iovalkey: 0.4.0
iovalkey: 0.4.0
pg: 8.23.0
pg: 8.23.0
- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0:
+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0:
dependencies:
dependencies:
jsonwebtoken: 9.0.3
jsonwebtoken: 9.0.3
+ linkedom: 0.18.14
+ transitivePeerDependencies:
+ - canvas
dependencies:
dependencies:
@@ -3042,6 +3194,8 @@ snapshots:
@@ -3042,6 +3194,8 @@ snapshots:
+ [email protected]: {}
+