openvibers/openvibe.games Commits
The portal's JSON-LD comes from openvibe-shared/seo (plan T11): WebSite plus the game as the site's primary type, injected as the page is served instead of a hand-written block in index.html. The page is built once per file version (mtime), so the game server never reads the file per request; it keeps cache-control: no-cache. discovery.test.ts covers the tags and the injection.
Changes
@@@ -34,44 +34,8 @@content="Play Scraplandia — multiplayer survival-building in your browser, no download."/><meta name="twitter:image" content="https://openvibe.games/og-image.png" />-- <script type="application/ld+json">-- {-- "@context": "https://schema.org",-- "@graph": [-- {-- "@type": "WebSite",-- "@id": "https://openvibe.games/#website",-- "name": "OpenVibe.Games",-- "url": "https://openvibe.games/",-- "description": "Browser games on the OpenVibe network — multiplayer, in your browser, no download.",-- "publisher": {-- "@type": "Organization",-- "name": "OpenVibe",-- "url": "https://openvibe.network/"-- }-- },-- {-- "@type": "VideoGame",-- "@id": "https://play.openvibe.games/#game",-- "name": "Scraplandia",-- "url": "https://play.openvibe.games/",-- "image": "https://openvibe.games/og-image.png",-- "description": "Scrappy multiplayer survival-building in and around Scrap City: physgun anything, weld it to anything else, chase supply drops in the wilds and haul your salvage home.",-- "genre": ["Survival", "Sandbox", "Building"],-- "playMode": "MultiPlayer",-- "gamePlatform": "Web browser",-- "applicationCategory": "Game",-- "operatingSystem": "Any",-- "isPartOf": { "@id": "https://openvibe.games/#website" },-- "publisher": {-- "@type": "Organization",-- "name": "OpenVibe",-- "url": "https://openvibe.network/"-- }-- }-- ]-- }-- </script>++ <!-- JSON-LD (WebSite + the game as the site's primary type) is injected as this page is served,++ built with openvibe-shared/seo so it stays in step with the other OpenVibe sites. --><style>* {margin: 0;nnew file mode 100644@@@ -0,0 +1,161 @@++import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'++import { request, type IncomingHttpHeaders, type OutgoingHttpHeaders, type Server } from 'node:http'++import type { AddressInfo } from 'node:net'++import { tmpdir } from 'node:os'++import { dirname, join } from 'node:path'++import { fileURLToPath } from 'node:url'++import { createConsoleLogger } from '@openvibe/shared'++import { afterAll, beforeAll, describe, expect, it } from 'vitest'++import { ServerMetrics } from '../observability/metrics.js'++import { createHttpServer } from './httpServer.js'++++/**++ * The crawl artifacts of plan T11 (audit §4 items 1-2): /robots.txt, /sitemap.xml and /llms.txt,++ * plus the home page's JSON-LD, fetched from the real HTTP layer over a stand-in client build.++ * Every one is built by openvibe-shared/seo; public pages only, and no private path anywhere.++ */++++const log = createConsoleLogger({ app: 'test' }, 'error')++const APEX = 'openvibe.games'++const PRIVATE = ['/auth/', '/api/', '/metrics', '/editor']++const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..')++++interface Reply {++ status: number++ headers: IncomingHttpHeaders++ body: string++}++++let dir: string++let server: Server++let port = 0++++function listen(s: Server): Promise<number> {++ return new Promise((resolve) =>++ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),++ )++}++++function send(++ path: string,++ opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders } = {},++): Promise<Reply> {++ return new Promise((resolve, reject) => {++ const req = request(++ {++ host: '127.0.0.1',++ port,++ path,++ method: opts.method ?? 'GET',++ headers: { host: opts.host ?? APEX, ...opts.headers },++ },++ (res) => {++ const chunks: Buffer[] = []++ res.on('data', (c: Buffer) => chunks.push(c))++ res.on('end', () =>++ resolve({++ status: res.statusCode ?? 0,++ headers: res.headers,++ body: Buffer.concat(chunks).toString('utf8'),++ }),++ )++ },++ )++ req.on('error', reject)++ req.end()++ })++}++++beforeAll(async () => {++ dir = mkdtempSync(join(tmpdir(), 'ovg-discovery-'))++ const dist = join(dir, 'dist')++ mkdirSync(dist, { recursive: true })++ // A stand-in portal build with a real </head>: the JSON-LD is injected before it as it is served.++ writeFileSync(++ join(dist, 'index.html'),++ '<!doctype html><html><head><title>portal page</title></head><body>portal</body></html>',++ )++ writeFileSync(++ join(dist, 'play.html'),++ '<!doctype html><html><head><title>game page</title></head><body>game</body></html>',++ )++ server = createHttpServer(dist, new ServerMetrics(), log)++ port = await listen(server)++})++++afterAll(() => {++ server.close()++ rmSync(dir, { recursive: true, force: true })++})++++describe('crawl artifacts (plan T11)', () => {++ it('serves /robots.txt: text/plain, the shared policy, the sitemap and every private path Disallowed', async () => {++ const r = await send('/robots.txt')++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')++ expect(r.headers['cache-control']).toBe('public, max-age=3600')++ expect(r.body).toContain('User-agent: *')++ expect(r.body).toContain('Sitemap: https://openvibe.games/sitemap.xml')++ // One real entry per private path — kept out of crawlers, never listed as a page.++ for (const p of PRIVATE) expect(r.body).toContain(`Disallow: ${p}`)++ // The shared toolkit welcomes AI and search crawlers by name.++ expect(r.body).toContain('User-agent: GPTBot')++ expect(r.body).toContain('User-agent: ClaudeBot')++ })++++ it('serves /sitemap.xml: XML, the public pages, a real lastmod from the data', async () => {++ const r = await send('/sitemap.xml')++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('application/xml; charset=utf-8')++ expect(r.body).toContain('<loc>https://openvibe.games/</loc>')++ expect(r.body).toContain('<loc>https://play.openvibe.games/</loc>')++ // The lastmod is the committed content-revision date, not the clock.++ const status = JSON.parse(readFileSync(join(ROOT, 'STATUS.json'), 'utf8')) as {++ updated: string++ }++ expect(r.body).toContain(`<lastmod>${status.updated.slice(0, 10)}</lastmod>`)++ for (const p of PRIVATE) expect(r.body).not.toContain(p)++ })++++ it('serves /llms.txt: text/plain, what the site is, a real page and its machine endpoints', async () => {++ const r = await send('/llms.txt')++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')++ expect(r.headers['cache-control']).toBe('public, max-age=3600')++ expect(r.body).toContain('# OpenVibe.Games')++ expect(r.body).toContain('- [OpenVibe.Games home](https://openvibe.games/)')++ expect(r.body).toContain('- [Scraplandia](https://play.openvibe.games/)')++ expect(r.body).toContain('https://openvibe.games/sitemap.xml')++ expect(r.body).toContain('https://openvibe.games/robots.txt')++ expect(r.body).toContain('https://openvibe.games/map.json')++ for (const p of PRIVATE) expect(r.body).not.toContain(p)++ })++++ it('injects the home page JSON-LD built by openvibe-shared/seo: WebSite + the primary type', async () => {++ const r = await send('/')++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')++ expect(r.body).toContain('<title>portal page</title>')++ const blocks = [++ ...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g),++ ]++ expect(blocks.map((b) => (JSON.parse(b[1] ?? '{}') as { '@type'?: string })['@type'])).toEqual([++ 'WebSite',++ 'WebApplication',++ 'WebPage',++ ])++ const nodes = blocks.map((b) => JSON.parse(b[1] ?? '{}') as Record<string, unknown>)++ expect(nodes[0]?.['url']).toBe('https://openvibe.games')++ expect(nodes[1]?.['applicationCategory']).toBe('GameApplication')++ // Nothing private or per-user in the served nodes.++ const text = JSON.stringify(nodes)++ for (const p of PRIVATE) expect(text).not.toContain(p)++ })++++ it('answers HEAD for the artifacts without a body', async () => {++ const r = await send('/sitemap.xml', { method: 'HEAD' })++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('application/xml; charset=utf-8')++ expect(r.body).toBe('')++ })++})nnew file mode 100644@@@ -0,0 +1,231 @@++import { existsSync, readFileSync } from 'node:fs'++import type { IncomingMessage, ServerResponse } from 'node:http'++import { createRequire } from 'node:module'++import { dirname, join } from 'node:path'++import { fileURLToPath } from 'node:url'++++/**++ * Crawl and machine-readability artifacts (plan T11; audit §4 items 1-2): GET /robots.txt,++ * GET /sitemap.xml and GET /llms.txt, plus the home page's JSON-LD. Every one is built with++ * openvibe-shared/seo — the toolkit the other OpenVibe sites run — so they say the same things++ * here as everywhere.++ *++ * Public pages only, and never the viewer: the portal and the game are listed; sign-in, the++ * per-user API, operational metrics and the noindex editor app are absent and Disallowed in++ * robots.txt. lastmod is a real date from the site's own data — the content-revision date++ * committed in the repository's STATUS.json, exactly as OpenVibe.Codes does — never "today" from++ * the clock, which would tell crawlers the whole site changed on every fetch.++ */++++interface LlmsLink {++ title: string++ url: string++ note?: string++}++interface LlmsSection {++ title: string++ links: LlmsLink[]++}++interface LlmsOptions {++ name: string++ summary: string++ details?: string++ sections?: LlmsSection[]++}++interface SeoUrl {++ loc: string++ lastmod?: string++ changefreq?: string++ priority?: number++}++interface SeoToolkit {++ llmsTxt(o: LlmsOptions): string++ robotsTxt(o: { sitemaps?: string[]; disallow?: string[]; allow?: string[] }): string++ sitemapXml(urls: SeoUrl[]): string++ jsonLd: {++ website(o: { name: string; url: string; description: string }): unknown++ softwareApp(o: {++ name: string++ url: string++ description: string++ category?: string++ keywords?: string++ }): unknown++ webPage(o: {++ name: string++ url: string++ description: string++ type?: string++ siteUrl?: string++ }): unknown++ }++ jsonLdTag(node: unknown): string++}++const seo = createRequire(import.meta.url)('openvibe-shared/seo') as SeoToolkit++++const SITE = 'https://openvibe.games'++const PLAY = 'https://play.openvibe.games'++const SITE_NAME = 'OpenVibe.Games'++const DESCRIPTION =++ 'Browser games on the OpenVibe network — multiplayer, in your browser, no download: Scraplandia is a persistent physics survival-building sandbox you can play as a guest.'++const GAME_DESCRIPTION =++ 'Scrappy multiplayer survival-building in and around Scrap City: physgun anything, weld it to anything else, chase supply drops in the wilds and haul your salvage home.'++// Every path robots.txt keeps out of crawlers. None is a public page, so none is in the sitemap++// or llms.txt either: sign-in, the per-user API, operational metrics and the noindex editor app.++const DISALLOW = ['/auth/', '/api/', '/metrics', '/editor']++++/** "2026-09-29" or "2026-09-29T12:00:00Z" as YYYY-MM-DD; null when the value is unusable. */++function dayOf(ts: unknown): string | null {++ const m = String(ts ?? '').match(/^(\d{4}-\d{2}-\d{2})/)++ return m ? (m[1] ?? null) : null++}++++/** The committed STATUS.json's content date: the site's own revision date, never the clock. */++function siteUpdated(): string | null {++ let dir = dirname(fileURLToPath(import.meta.url))++ for (let i = 0; i < 6; i += 1) {++ const candidate = join(dir, 'STATUS.json')++ if (existsSync(candidate)) {++ try {++ const status = JSON.parse(readFileSync(candidate, 'utf8')) as { updated?: unknown }++ return dayOf(status.updated)++ } catch {++ return null++ }++ }++ const up = dirname(dir)++ if (up === dir) break++ dir = up++ }++ return null++}++++/** The site's fixed public pages, each with the same real content-revision lastmod. */++function publicPages(): SeoUrl[] {++ const lastmod = siteUpdated()++ const at = (loc: string, changefreq: string, priority: number): SeoUrl => ({++ loc,++ changefreq,++ priority,++ ...(lastmod ? { lastmod } : {}),++ })++ return [at(`${SITE}/`, 'weekly', 1.0), at(`${PLAY}/`, 'weekly', 0.9)]++}++++/** The /llms.txt body: what the site is, its public pages and its machine-readable endpoints. */++function llmsBody(): string {++ return seo.llmsTxt({++ name: SITE_NAME,++ summary:++ 'OpenVibe.Games: the games arm of the OpenVibe network — browser games you can start in a tab with no install, and the persistent multiplayer physics sandbox Scraplandia.',++ details:++ 'Everything public can be played as a guest; a signed-in OpenVibe account only adds saved characters and one identity across the network. The portal and the game are plain HTML pages, and the world this game server runs is published as the canonical map document at /map.json. Sign-in, the per-user API, metrics and the editor are not crawler or model input and are listed nowhere here.',++ sections: [++ {++ title: 'Public pages',++ links: [++ {++ title: 'OpenVibe.Games home',++ url: `${SITE}/`,++ note: 'the portal: what there is to play, and what signing in adds',++ },++ {++ title: 'Scraplandia',++ url: `${PLAY}/`,++ note: 'the live multiplayer game, playable as a guest in the browser',++ },++ ],++ },++ {++ title: 'Machine-readable',++ links: [++ {++ title: 'Sitemap',++ url: `${SITE}/sitemap.xml`,++ note: 'the public pages above, each with a real lastmod',++ },++ {++ title: 'robots.txt',++ url: `${SITE}/robots.txt`,++ note: 'search and AI crawlers are welcome on the public pages',++ },++ {++ title: 'World map (JSON)',++ url: `${SITE}/map.json`,++ note: 'the canonical world document this game server runs',++ },++ ],++ },++ ],++ })++}++++/**++ * The home page's JSON-LD: the site as a WebSite, the game as the site's primary type (the shared++ * kit's WebApplication, category GameApplication) and the portal page that carries them. The same++ * nodes for every crawler; nothing about the reader.++ */++export function homeJsonLd(): unknown[] {++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: SITE, description: DESCRIPTION }),++ seo.jsonLd.softwareApp({++ name: 'Scraplandia',++ url: `${PLAY}/`,++ description: GAME_DESCRIPTION,++ category: 'GameApplication',++ keywords: 'browser game, multiplayer, physics sandbox, survival, building, openvibe',++ }),++ seo.jsonLd.webPage({++ name: SITE_NAME,++ url: `${SITE}/`,++ description: DESCRIPTION,++ siteUrl: SITE,++ }),++ ]++}++++/** The home page's JSON-LD as `<script>` tags, ready to inject before `</head>`. */++export function homeJsonLdTags(): string {++ return homeJsonLd()++ .map((node) => seo.jsonLdTag(node))++ .join('\n')++}++++function send(res: ServerResponse, type: string, body: string): void {++ res.writeHead(200, {++ 'content-type': `${type}; charset=utf-8`,++ 'cache-control': 'public, max-age=3600',++ })++ res.end(body)++}++++/**++ * Answers the crawl artifacts this server owns. Returns true when it handled the request, so the++ * router in httpServer drops through to nothing else. HEAD answers like GET without a body (node++ * suppresses it), so a crawler can read the headers first.++ */++export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolean {++ const url = (rq.url ?? '/').split('?')[0] ?? '/'++ if (url !== '/robots.txt' && url !== '/sitemap.xml' && url !== '/llms.txt') return false++ if (rq.method !== 'GET' && rq.method !== 'HEAD') {++ res.writeHead(405, { allow: 'GET, HEAD' })++ res.end()++ return true++ }++ if (url === '/robots.txt') {++ // The shared toolkit names search and AI crawlers by name, keeps every Disallow below and++ // always names the sitemap.++ send(++ res,++ 'text/plain',++ seo.robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`], disallow: DISALLOW }),++ )++ return true++ }++ if (url === '/sitemap.xml') {++ send(res, 'application/xml', seo.sitemapXml(publicPages()))++ return true++ }++ send(res, 'text/plain', llmsBody())++ return true++}@@@ -1,5 +1,5 @@import { randomBytes } from 'node:crypto'--import { createReadStream, existsSync, statSync } from 'node:fs'++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 type { Logger } from '@openvibe/shared'@@@ -29,6 +29,22 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStoreimport type { GamesActorLimits } from './actorLimits.js'import { resolveClientAddress } from './clientAddress.js'import { sendNotFound, type NotFoundLink } from './notFound.js'++import { discoveryHandler, homeJsonLdTags } from './discovery.js'++++/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file++ * per request (a new build changes the mtime and rebuilds it once). */++let portalMemo: { path: string; mtimeMs: number; html: string } | null = null++function portalHtml(filePath: string): string {++ const mtimeMs = statSync(filePath).mtimeMs++ if (portalMemo && portalMemo.path === filePath && portalMemo.mtimeMs === mtimeMs)++ return portalMemo.html++ const raw = readFileSync(filePath, 'utf8')++ const html = raw.includes('</head>')++ ? raw.replace('</head>', `${homeJsonLdTags()}\n </head>`)++ : raw++ portalMemo = { path: filePath, mtimeMs, html }++ return html++}import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'import type { MapFileV2 } from '@openvibe/content'@@@ -197,6 +213,8 @@ export function createHttpServer(return}if (platform?.handle?.(req, res)) return++ // Crawl artifacts (plan T11): /robots.txt, /sitemap.xml and /llms.txt, built by openvibe-shared/seo.++ if (discoveryHandler(req, res)) returnif (url === '/map.json' && mapPath) {// The canonical v2 document, with its revision as an ETag. This IS the// wire format.@@@ -727,6 +745,16 @@ export function createHttpServer(notFound()return}++ // The portal is a static build, so its JSON-LD is injected as the page is served: the same++ // openvibe-shared/seo builders every OpenVibe site uses, one response for every crawler.++ if (pageAlias === 'index.html' || url === '/index.html') {++ res.writeHead(200, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'no-cache',++ })++ res.end(portalHtml(filePath))++ return++ }const type = MIME[extname(filePath)] ?? 'application/octet-stream'res.writeHead(200, {'content-type': type,
@@ -34,44 +34,8 @@
@@ -34,44 +34,8 @@
content="Play Scraplandia — multiplayer survival-building in your browser, no download."
content="Play Scraplandia — multiplayer survival-building in your browser, no download."
/>
/>
<meta name="twitter:image" content="https://openvibe.games/og-image.png" />
<meta name="twitter:image" content="https://openvibe.games/og-image.png" />
- <script type="application/ld+json">
- {
- "@context": "https://schema.org",
- "@graph": [
- {
- "@type": "WebSite",
- "@id": "https://openvibe.games/#website",
- "name": "OpenVibe.Games",
- "url": "https://openvibe.games/",
- "description": "Browser games on the OpenVibe network — multiplayer, in your browser, no download.",
- "publisher": {
- "@type": "Organization",
- "name": "OpenVibe",
- "url": "https://openvibe.network/"
- }
- },
- {
- "@type": "VideoGame",
- "@id": "https://play.openvibe.games/#game",
- "name": "Scraplandia",
- "url": "https://play.openvibe.games/",
- "image": "https://openvibe.games/og-image.png",
- "description": "Scrappy multiplayer survival-building in and around Scrap City: physgun anything, weld it to anything else, chase supply drops in the wilds and haul your salvage home.",
- "genre": ["Survival", "Sandbox", "Building"],
- "playMode": "MultiPlayer",
- "gamePlatform": "Web browser",
- "applicationCategory": "Game",
- "operatingSystem": "Any",
- "isPartOf": { "@id": "https://openvibe.games/#website" },
- "publisher": {
- "@type": "Organization",
- "name": "OpenVibe",
- "url": "https://openvibe.network/"
- }
- }
- ]
- }
- </script>
+ <!-- JSON-LD (WebSite + the game as the site's primary type) is injected as this page is served,
+ built with openvibe-shared/seo so it stays in step with the other OpenVibe sites. -->
<style>
<style>
* {
* {
margin: 0;
margin: 0;
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { request, type IncomingHttpHeaders, type OutgoingHttpHeaders, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createConsoleLogger } from '@openvibe/shared'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { ServerMetrics } from '../observability/metrics.js'
+import { createHttpServer } from './httpServer.js'
+
+/**
+ * The crawl artifacts of plan T11 (audit §4 items 1-2): /robots.txt, /sitemap.xml and /llms.txt,
+ * plus the home page's JSON-LD, fetched from the real HTTP layer over a stand-in client build.
+ * Every one is built by openvibe-shared/seo; public pages only, and no private path anywhere.
+ */
+
+const log = createConsoleLogger({ app: 'test' }, 'error')
+const APEX = 'openvibe.games'
+const PRIVATE = ['/auth/', '/api/', '/metrics', '/editor']
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..')
+
+interface Reply {
+ status: number
+ headers: IncomingHttpHeaders
+ body: string
+}
+
+let dir: string
+let server: Server
+let port = 0
+
+function listen(s: Server): Promise<number> {
+ return new Promise((resolve) =>
+ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),
+ )
+}
+
+function send(
+ path: string,
+ opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders } = {},
+): Promise<Reply> {
+ return new Promise((resolve, reject) => {
+ const req = request(
+ {
+ host: '127.0.0.1',
+ port,
+ path,
+ method: opts.method ?? 'GET',
+ headers: { host: opts.host ?? APEX, ...opts.headers },
+ },
+ (res) => {
+ const chunks: Buffer[] = []
+ res.on('data', (c: Buffer) => chunks.push(c))
+ res.on('end', () =>
+ resolve({
+ status: res.statusCode ?? 0,
+ headers: res.headers,
+ body: Buffer.concat(chunks).toString('utf8'),
+ }),
+ )
+ },
+ )
+ req.on('error', reject)
+ req.end()
+ })
+}
+
+beforeAll(async () => {
+ dir = mkdtempSync(join(tmpdir(), 'ovg-discovery-'))
+ const dist = join(dir, 'dist')
+ mkdirSync(dist, { recursive: true })
+ // A stand-in portal build with a real </head>: the JSON-LD is injected before it as it is served.
+ writeFileSync(
+ join(dist, 'index.html'),
+ '<!doctype html><html><head><title>portal page</title></head><body>portal</body></html>',
+ )
+ writeFileSync(
+ join(dist, 'play.html'),
+ '<!doctype html><html><head><title>game page</title></head><body>game</body></html>',
+ )
+ server = createHttpServer(dist, new ServerMetrics(), log)
+ port = await listen(server)
+})
+
+afterAll(() => {
+ server.close()
+ rmSync(dir, { recursive: true, force: true })
+})
+
+describe('crawl artifacts (plan T11)', () => {
+ it('serves /robots.txt: text/plain, the shared policy, the sitemap and every private path Disallowed', async () => {
+ const r = await send('/robots.txt')
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')
+ expect(r.headers['cache-control']).toBe('public, max-age=3600')
+ expect(r.body).toContain('User-agent: *')
+ expect(r.body).toContain('Sitemap: https://openvibe.games/sitemap.xml')
+ // One real entry per private path — kept out of crawlers, never listed as a page.
+ for (const p of PRIVATE) expect(r.body).toContain(`Disallow: ${p}`)
+ // The shared toolkit welcomes AI and search crawlers by name.
+ expect(r.body).toContain('User-agent: GPTBot')
+ expect(r.body).toContain('User-agent: ClaudeBot')
+ })
+
+ it('serves /sitemap.xml: XML, the public pages, a real lastmod from the data', async () => {
+ const r = await send('/sitemap.xml')
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('application/xml; charset=utf-8')
+ expect(r.body).toContain('<loc>https://openvibe.games/</loc>')
+ expect(r.body).toContain('<loc>https://play.openvibe.games/</loc>')
+ // The lastmod is the committed content-revision date, not the clock.
+ const status = JSON.parse(readFileSync(join(ROOT, 'STATUS.json'), 'utf8')) as {
+ updated: string
+ }
+ expect(r.body).toContain(`<lastmod>${status.updated.slice(0, 10)}</lastmod>`)
+ for (const p of PRIVATE) expect(r.body).not.toContain(p)
+ })
+
+ it('serves /llms.txt: text/plain, what the site is, a real page and its machine endpoints', async () => {
+ const r = await send('/llms.txt')
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')
+ expect(r.headers['cache-control']).toBe('public, max-age=3600')
+ expect(r.body).toContain('# OpenVibe.Games')
+ expect(r.body).toContain('- [OpenVibe.Games home](https://openvibe.games/)')
+ expect(r.body).toContain('- [Scraplandia](https://play.openvibe.games/)')
+ expect(r.body).toContain('https://openvibe.games/sitemap.xml')
+ expect(r.body).toContain('https://openvibe.games/robots.txt')
+ expect(r.body).toContain('https://openvibe.games/map.json')
+ for (const p of PRIVATE) expect(r.body).not.toContain(p)
+ })
+
+ it('injects the home page JSON-LD built by openvibe-shared/seo: WebSite + the primary type', async () => {
+ const r = await send('/')
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
+ expect(r.body).toContain('<title>portal page</title>')
+ const blocks = [
+ ...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g),
+ ]
+ expect(blocks.map((b) => (JSON.parse(b[1] ?? '{}') as { '@type'?: string })['@type'])).toEqual([
+ 'WebSite',
+ 'WebApplication',
+ 'WebPage',
+ ])
+ const nodes = blocks.map((b) => JSON.parse(b[1] ?? '{}') as Record<string, unknown>)
+ expect(nodes[0]?.['url']).toBe('https://openvibe.games')
+ expect(nodes[1]?.['applicationCategory']).toBe('GameApplication')
+ // Nothing private or per-user in the served nodes.
+ const text = JSON.stringify(nodes)
+ for (const p of PRIVATE) expect(text).not.toContain(p)
+ })
+
+ it('answers HEAD for the artifacts without a body', async () => {
+ const r = await send('/sitemap.xml', { method: 'HEAD' })
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('application/xml; charset=utf-8')
+ expect(r.body).toBe('')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
+import { existsSync, readFileSync } from 'node:fs'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+/**
+ * Crawl and machine-readability artifacts (plan T11; audit §4 items 1-2): GET /robots.txt,
+ * GET /sitemap.xml and GET /llms.txt, plus the home page's JSON-LD. Every one is built with
+ * openvibe-shared/seo — the toolkit the other OpenVibe sites run — so they say the same things
+ * here as everywhere.
+ *
+ * Public pages only, and never the viewer: the portal and the game are listed; sign-in, the
+ * per-user API, operational metrics and the noindex editor app are absent and Disallowed in
+ * robots.txt. lastmod is a real date from the site's own data — the content-revision date
+ * committed in the repository's STATUS.json, exactly as OpenVibe.Codes does — never "today" from
+ * the clock, which would tell crawlers the whole site changed on every fetch.
+ */
+
+interface LlmsLink {
+ title: string
+ url: string
+ note?: string
+}
+interface LlmsSection {
+ title: string
+ links: LlmsLink[]
+}
+interface LlmsOptions {
+ name: string
+ summary: string
+ details?: string
+ sections?: LlmsSection[]
+}
+interface SeoUrl {
+ loc: string
+ lastmod?: string
+ changefreq?: string
+ priority?: number
+}
+interface SeoToolkit {
+ llmsTxt(o: LlmsOptions): string
+ robotsTxt(o: { sitemaps?: string[]; disallow?: string[]; allow?: string[] }): string
+ sitemapXml(urls: SeoUrl[]): string
+ jsonLd: {
+ website(o: { name: string; url: string; description: string }): unknown
+ softwareApp(o: {
+ name: string
+ url: string
+ description: string
+ category?: string
+ keywords?: string
+ }): unknown
+ webPage(o: {
+ name: string
+ url: string
+ description: string
+ type?: string
+ siteUrl?: string
+ }): unknown
+ }
+ jsonLdTag(node: unknown): string
+}
+const seo = createRequire(import.meta.url)('openvibe-shared/seo') as SeoToolkit
+
+const SITE = 'https://openvibe.games'
+const PLAY = 'https://play.openvibe.games'
+const SITE_NAME = 'OpenVibe.Games'
+const DESCRIPTION =
+ 'Browser games on the OpenVibe network — multiplayer, in your browser, no download: Scraplandia is a persistent physics survival-building sandbox you can play as a guest.'
+const GAME_DESCRIPTION =
+ 'Scrappy multiplayer survival-building in and around Scrap City: physgun anything, weld it to anything else, chase supply drops in the wilds and haul your salvage home.'
+// Every path robots.txt keeps out of crawlers. None is a public page, so none is in the sitemap
+// or llms.txt either: sign-in, the per-user API, operational metrics and the noindex editor app.
+const DISALLOW = ['/auth/', '/api/', '/metrics', '/editor']
+
+/** "2026-09-29" or "2026-09-29T12:00:00Z" as YYYY-MM-DD; null when the value is unusable. */
+function dayOf(ts: unknown): string | null {
+ const m = String(ts ?? '').match(/^(\d{4}-\d{2}-\d{2})/)
+ return m ? (m[1] ?? null) : null
+}
+
+/** The committed STATUS.json's content date: the site's own revision date, never the clock. */
+function siteUpdated(): string | null {
+ let dir = dirname(fileURLToPath(import.meta.url))
+ for (let i = 0; i < 6; i += 1) {
+ const candidate = join(dir, 'STATUS.json')
+ if (existsSync(candidate)) {
+ try {
+ const status = JSON.parse(readFileSync(candidate, 'utf8')) as { updated?: unknown }
+ return dayOf(status.updated)
+ } catch {
+ return null
+ }
+ }
+ const up = dirname(dir)
+ if (up === dir) break
+ dir = up
+ }
+ return null
+}
+
+/** The site's fixed public pages, each with the same real content-revision lastmod. */
+function publicPages(): SeoUrl[] {
+ const lastmod = siteUpdated()
+ const at = (loc: string, changefreq: string, priority: number): SeoUrl => ({
+ loc,
+ changefreq,
+ priority,
+ ...(lastmod ? { lastmod } : {}),
+ })
+ return [at(`${SITE}/`, 'weekly', 1.0), at(`${PLAY}/`, 'weekly', 0.9)]
+}
+
+/** The /llms.txt body: what the site is, its public pages and its machine-readable endpoints. */
+function llmsBody(): string {
+ return seo.llmsTxt({
+ name: SITE_NAME,
+ summary:
+ 'OpenVibe.Games: the games arm of the OpenVibe network — browser games you can start in a tab with no install, and the persistent multiplayer physics sandbox Scraplandia.',
+ details:
+ 'Everything public can be played as a guest; a signed-in OpenVibe account only adds saved characters and one identity across the network. The portal and the game are plain HTML pages, and the world this game server runs is published as the canonical map document at /map.json. Sign-in, the per-user API, metrics and the editor are not crawler or model input and are listed nowhere here.',
+ sections: [
+ {
+ title: 'Public pages',
+ links: [
+ {
+ title: 'OpenVibe.Games home',
+ url: `${SITE}/`,
+ note: 'the portal: what there is to play, and what signing in adds',
+ },
+ {
+ title: 'Scraplandia',
+ url: `${PLAY}/`,
+ note: 'the live multiplayer game, playable as a guest in the browser',
+ },
+ ],
+ },
+ {
+ title: 'Machine-readable',
+ links: [
+ {
+ title: 'Sitemap',
+ url: `${SITE}/sitemap.xml`,
+ note: 'the public pages above, each with a real lastmod',
+ },
+ {
+ title: 'robots.txt',
+ url: `${SITE}/robots.txt`,
+ note: 'search and AI crawlers are welcome on the public pages',
+ },
+ {
+ title: 'World map (JSON)',
+ url: `${SITE}/map.json`,
+ note: 'the canonical world document this game server runs',
+ },
+ ],
+ },
+ ],
+ })
+}
+
+/**
+ * The home page's JSON-LD: the site as a WebSite, the game as the site's primary type (the shared
+ * kit's WebApplication, category GameApplication) and the portal page that carries them. The same
+ * nodes for every crawler; nothing about the reader.
+ */
+export function homeJsonLd(): unknown[] {
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: SITE, description: DESCRIPTION }),
+ seo.jsonLd.softwareApp({
+ name: 'Scraplandia',
+ url: `${PLAY}/`,
+ description: GAME_DESCRIPTION,
+ category: 'GameApplication',
+ keywords: 'browser game, multiplayer, physics sandbox, survival, building, openvibe',
+ }),
+ seo.jsonLd.webPage({
+ name: SITE_NAME,
+ url: `${SITE}/`,
+ description: DESCRIPTION,
+ siteUrl: SITE,
+ }),
+ ]
+}
+
+/** The home page's JSON-LD as `<script>` tags, ready to inject before `</head>`. */
+export function homeJsonLdTags(): string {
+ return homeJsonLd()
+ .map((node) => seo.jsonLdTag(node))
+ .join('\n')
+}
+
+function send(res: ServerResponse, type: string, body: string): void {
+ res.writeHead(200, {
+ 'content-type': `${type}; charset=utf-8`,
+ 'cache-control': 'public, max-age=3600',
+ })
+ res.end(body)
+}
+
+/**
+ * Answers the crawl artifacts this server owns. Returns true when it handled the request, so the
+ * router in httpServer drops through to nothing else. HEAD answers like GET without a body (node
+ * suppresses it), so a crawler can read the headers first.
+ */
+export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolean {
+ const url = (rq.url ?? '/').split('?')[0] ?? '/'
+ if (url !== '/robots.txt' && url !== '/sitemap.xml' && url !== '/llms.txt') return false
+ if (rq.method !== 'GET' && rq.method !== 'HEAD') {
+ res.writeHead(405, { allow: 'GET, HEAD' })
+ res.end()
+ return true
+ }
+ if (url === '/robots.txt') {
+ // The shared toolkit names search and AI crawlers by name, keeps every Disallow below and
+ // always names the sitemap.
+ send(
+ res,
+ 'text/plain',
+ seo.robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`], disallow: DISALLOW }),
+ )
+ return true
+ }
+ if (url === '/sitemap.xml') {
+ send(res, 'application/xml', seo.sitemapXml(publicPages()))
+ return true
+ }
+ send(res, 'text/plain', llmsBody())
+ return true
+}
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
import { randomBytes } from 'node:crypto'
import { randomBytes } from 'node:crypto'
-import { createReadStream, existsSync, 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 } from 'node:path'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
@@ -29,6 +29,22 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore
@@ -29,6 +29,22 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore
import type { GamesActorLimits } from './actorLimits.js'
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 } from './discovery.js'
+
+/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
+ * per request (a new build changes the mtime and rebuilds it once). */
+let portalMemo: { path: string; mtimeMs: number; html: string } | null = null
+function portalHtml(filePath: string): string {
+ const mtimeMs = statSync(filePath).mtimeMs
+ if (portalMemo && portalMemo.path === filePath && portalMemo.mtimeMs === mtimeMs)
+ return portalMemo.html
+ const raw = readFileSync(filePath, 'utf8')
+ const html = raw.includes('</head>')
+ ? raw.replace('</head>', `${homeJsonLdTags()}\n </head>`)
+ : raw
+ portalMemo = { path: filePath, mtimeMs, html }
+ return html
+}
import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import type { MapFileV2 } from '@openvibe/content'
import type { MapFileV2 } from '@openvibe/content'
@@ -197,6 +213,8 @@ export function createHttpServer(
@@ -197,6 +213,8 @@ export function createHttpServer(
return
return
}
}
if (platform?.handle?.(req, res)) return
if (platform?.handle?.(req, res)) return
+ // Crawl artifacts (plan T11): /robots.txt, /sitemap.xml and /llms.txt, built by openvibe-shared/seo.
+ if (discoveryHandler(req, res)) return
if (url === '/map.json' && mapPath) {
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
// The canonical v2 document, with its revision as an ETag. This IS the
// wire format.
// wire format.
@@ -727,6 +745,16 @@ export function createHttpServer(
@@ -727,6 +745,16 @@ export function createHttpServer(
notFound()
notFound()
return
return
}
}
+ // The portal is a static build, so its JSON-LD is injected as the page is served: the same
+ // openvibe-shared/seo builders every OpenVibe site uses, one response for every crawler.
+ if (pageAlias === 'index.html' || url === '/index.html') {
+ res.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-cache',
+ })
+ res.end(portalHtml(filePath))
+ 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,