openvibers/openvibe.games Commits
Adopt openvibe-shared v2.6.0 and serve llms-full.txt plus IndexNow key
Changes
@@@ -24,3 +24,9 @@ HOST=0.0.0.0# comma-separated IP/CIDR list. Production: 127.0.0.1 (the local nginx, which overwrites those headers).TRUST_PROXY=MAP_PATH=data/map.json++++# ── IndexNow (optional) ──++# The IndexNow protocol key (8-128 hex or alphanumeric), as IndexNow's own key generator produces it.++# When set, GET /<key>.txt serves the key file the protocol requires engines to fetch; unset means++# off. This site has no user-created public pages, so a key alone pings nothing.++INDEXNOW_KEY=@@@ -32,7 +32,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)--- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release++- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v2.6.0 (pinned by releasetarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite indevelopment), Valkey (`iovalkey`, optional)@@@ -6,7 +6,7 @@"runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional","port": 8000,"domain": "openvibe.games, play.openvibe.games",-- "contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",++ "contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v2.6.0 (apps/server/package.json)","capabilities": ["games.mod.manage","games.mod.read",@@@ -22,7 +22,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.78.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pg": "^8.23.0","quickjs-emscripten": "0.32.0","tsx": "^4.19.3",@@@ -68,3 +68,12 @@ describe('loadConfig scriptMods', () => {expect(loadConfig({ GAMES_SCRIPT_MODS: 'true' }).scriptMods).toBe(true)})})++++describe('loadConfig indexnowKey', () => {++ it('is empty (IndexNow off) unless INDEXNOW_KEY is set, and never carries padding', () => {++ expect(loadConfig({}).indexnowKey).toBe('')++ expect(loadConfig({ INDEXNOW_KEY: '' }).indexnowKey).toBe('')++ expect(loadConfig({ INDEXNOW_KEY: ' ' }).indexnowKey).toBe('')++ expect(loadConfig({ INDEXNOW_KEY: ' testkey0testkey0 ' }).indexnowKey).toBe('testkey0testkey0')++ })++})@@@ -68,6 +68,12 @@ export interface ServerConfig {* place. Off by default; the mods stay in the def set (and its digest) either way.*/scriptMods: boolean++ /**++ * INDEXNOW_KEY: the IndexNow protocol key (8–128 hex or alphanumeric). When set, the key file is++ * served at /<key>.txt so engines can verify it. Empty: off — nothing is mounted and nothing is++ * sent.++ */++ indexnowKey: string}export interface PlatformConfig {@@@ -153,6 +159,8 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {}: null,platform: loadPlatformConfig(env),++ /** IndexNow is off until a key is configured; an empty key never mounts the key file. */++ indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),tickRate: 30,snapshotEvery: 2,interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),@@@ -20,6 +20,7 @@ import {SOCKETS_MS,within,} from './net/gracefulStop.js'++import { createGamesIndexNow } from './net/indexnow.js'import { createHttpServer } from './net/httpServer.js'import { resolveNetworkUser } from './net/networkAuth.js'import { acceptUpgrade, attachWebSocket } from './net/wsTransport.js'@@@ -422,6 +423,11 @@ async function main(): Promise<void> {clientAddress,},ticketStore,++ createGamesIndexNow(++ config.oauth?.selfUrl ?? 'https://openvibe.games',++ config.indexnowKey,++ log.child({ system: 'indexnow' }),++ ),)const drainer = httpDrainer(http)world.reconcileMapNodes()@@@ -6,19 +6,24 @@ 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 { loadConfig } from '../config.js'import { ServerMetrics } from '../observability/metrics.js'import { createHttpServer } from './httpServer.js'++import { createGamesIndexNow } from './indexnow.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.++ * The crawl artifacts of plan T11 (audit §4 items 1-2): /robots.txt, /sitemap.xml, /llms.txt and++ * /llms-full.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. The IndexNow key file is exercised here too: it is the other root-level text artifact++ * this layer serves, and its on/off state is a configuration difference rather than a route.*/const log = createConsoleLogger({ app: 'test' }, 'error')const APEX = 'openvibe.games'const PRIVATE = ['/auth/', '/api/', '/metrics', '/editor']const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..')++const INDEXNOW_KEY = 'testkey0testkey0'interface Reply {status: number@@@ -28,7 +33,9 @@ interface Reply {let dir: stringlet server: Server++let keyedServer: Serverlet port = 0++let keyedPort = 0function listen(s: Server): Promise<number> {return new Promise((resolve) =>@@@ -38,13 +45,13 @@ function listen(s: Server): Promise<number> {function send(path: string,-- opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders } = {},++ opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders; port?: number } = {},): Promise<Reply> {return new Promise((resolve, reject) => {const req = request({host: '127.0.0.1',-- port,++ port: opts.port ?? port,path,method: opts.method ?? 'GET',headers: { host: opts.host ?? APEX, ...opts.headers },@@@ -79,12 +86,43 @@ beforeAll(async () => {join(dist, 'play.html'),'<!doctype html><html><head><title>game page</title></head><body>game</body></html>',)-- server = createHttpServer(dist, new ServerMetrics(), log)++ // Two instances of the same server over the same build: one with no IndexNow key (off) and one++ // with a key (on), so the difference is the configuration and nothing else.++ server = createHttpServer(++ dist,++ new ServerMetrics(),++ log,++ undefined,++ undefined,++ undefined,++ undefined,++ undefined,++ undefined,++ undefined,++ // No INDEXNOW_KEY: the same wiring as main.ts, with the key unset — the whole "off" state.++ createGamesIndexNow('https://openvibe.games', loadConfig({}).indexnowKey, log),++ )port = await listen(server)++ keyedServer = createHttpServer(++ dist,++ new ServerMetrics(),++ log,++ undefined,++ undefined,++ undefined,++ undefined,++ undefined,++ undefined,++ undefined,++ // The key comes from the config, exactly as main.ts reads it: INDEXNOW_KEY, trimmed.++ createGamesIndexNow('https://openvibe.games', loadConfig({ INDEXNOW_KEY }).indexnowKey, log),++ )++ keyedPort = await listen(keyedServer)})afterAll(() => {server.close()++ keyedServer.close()rmSync(dir, { recursive: true, force: true })})@@@ -131,6 +169,38 @@ describe('crawl artifacts (plan T11)', () => {for (const p of PRIVATE) expect(r.body).not.toContain(p)})++ it('serves /llms-full.txt: text/plain, the home page, and nothing robots keeps out', async () => {++ const r = await send('/llms-full.txt')++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')++ // The same cache lifetime as /llms.txt: the same site description, as one document.++ expect(r.headers['cache-control']).toBe('public, max-age=3600')++ // The home URL, and the site's own header and summary, exactly as /llms.txt opens with.++ expect(r.body).toContain('https://openvibe.games/')++ expect(r.body).toContain('# OpenVibe.Games')++ const index = await send('/llms.txt')++ const summary = /(^|> )(OpenVibe\.Games: the games arm.*)$/m.exec(index.body)?.[1]++ expect(summary).toBeTruthy()++ expect(r.body).toContain(summary as string)++ // Every public page /llms.txt lists appears, with its text.++ expect(r.body).toContain('https://play.openvibe.games/')++ // Public pages only: no path from the robots Disallow list, and no per-user or editor surface.++ for (const p of PRIVATE) expect(r.body).not.toContain(p)++ })++++ it('serves the IndexNow key file when INDEXNOW_KEY is set, and 404 when it is not', async () => {++ const on = await send(`/${INDEXNOW_KEY}.txt`, { port: keyedPort })++ expect(on.status).toBe(200)++ expect(on.headers['content-type']).toBe('text/plain; charset=utf-8')++ expect(on.body).toBe(INDEXNOW_KEY)++ // Unset: nothing is mounted, so the path is an ordinary 404 — it never says a key is missing.++ const off = await send(`/${INDEXNOW_KEY}.txt`)++ expect(off.status).toBe(404)++ // Any other .txt is untouched on a keyed instance: the key file serves only its own name.++ const other = await send('/someother.txt', { port: keyedPort })++ expect(other.status).toBe(404)++ })++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)@@@ -6,9 +6,9 @@ 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.++ * GET /sitemap.xml, GET /llms.txt and GET /llms-full.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@@@ -38,8 +38,27 @@ interface SeoUrl {changefreq?: stringpriority?: number}++interface LlmsFullPage {++ title: string++ url: string++ /** The page's own text; here the site's own description of it, never scraped HTML. */++ text?: string++}++interface LlmsFullSection {++ title: string++ pages: LlmsFullPage[]++}++interface LlmsFullOptions {++ /** The site name and base URL: the document opens `# <name>`, and page URLs resolve against it. */++ site: { name: string; url: string }++ summary: string++ sections?: LlmsFullSection[]++ /** Hard byte ceiling: a page that would cross it is dropped, never truncated mid-word. */++ maxBytes?: number++}interface SeoToolkit {llmsTxt(o: LlmsOptions): string++ llmsFull(o: LlmsFullOptions): stringrobotsTxt(o: { sitemaps?: string[]; disallow?: string[]; allow?: string[] }): stringsitemapXml(urls: SeoUrl[]): stringjsonLd: {@@@ -70,6 +89,12 @@ 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.'++// The one summary and one detail paragraph /llms.txt and /llms-full.txt both open with: a model++// reading the full document must meet the same description of the site as one reading the index.++const SITE_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.'++const SITE_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.'// 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']@@@ -109,32 +134,51 @@ function publicPages(): SeoUrl[] {priority,...(lastmod ? { lastmod } : {}),})-- return [at(`${SITE}/`, 'weekly', 1.0), at(`${PLAY}/`, 'weekly', 0.9)]++ return publicPageDocs().map((p) => at(p.url, 'weekly', p.priority))++}++++/**++ * The site's public pages, once, as the data both artifacts read: llms.txt lists them with a note,++ * llms-full.txt carries the same pages with their text. One source, so the two can never disagree++ * about what is public. There are no user-created public pages here — the world is a game document++ * served at /map.json, not a page — so this list is fixed.++ */++interface PublicPageDoc {++ title: string++ url: string++ note: string++ text: string++ priority: number++}++function publicPageDocs(): PublicPageDoc[] {++ return [++ {++ title: 'OpenVibe.Games home',++ url: `${SITE}/`,++ note: 'the portal: what there is to play, and what signing in adds',++ text: DESCRIPTION,++ priority: 1.0,++ },++ {++ title: 'Scraplandia',++ url: `${PLAY}/`,++ note: 'the live multiplayer game, playable as a guest in the browser',++ text: GAME_DESCRIPTION,++ priority: 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.',++ summary: SITE_SUMMARY,++ details: SITE_DETAILS,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',-- },-- ],++ links: publicPageDocs().map((p) => ({ title: p.title, url: p.url, note: p.note })),},{title: 'Machine-readable',@@@ -149,6 +193,11 @@ function llmsBody(): string {url: `${SITE}/robots.txt`,note: 'search and AI crawlers are welcome on the public pages',},++ {++ title: 'Full text (llms-full.txt)',++ url: `${SITE}/llms-full.txt`,++ note: 'this site’s public pages as one text document, for a model to read whole',++ },{title: 'World map (JSON)',url: `${SITE}/map.json`,@@@ -160,6 +209,28 @@ function llmsBody(): string {})}++/**++ * The /llms-full.txt body: the same site header and summary as /llms.txt, then every public page++ * llms.txt lists with its own text — the two descriptions the site's JSON-LD already publishes,++ * which are the site's own words for those pages rather than scraped markup. Public pages only, so++ * nothing under robots' Disallow can reach a model, and a 512 KB ceiling keeps the answer a++ * document rather than a payload.++ */++const LLMS_FULL_MAX_BYTES = 512 * 1024++function llmsFullBody(): string {++ return seo.llmsFull({++ site: { name: SITE_NAME, url: SITE },++ summary: SITE_SUMMARY,++ sections: [++ {++ title: 'Public pages',++ pages: publicPageDocs().map((p) => ({ title: p.title, url: p.url, text: p.text })),++ },++ ],++ maxBytes: LLMS_FULL_MAX_BYTES,++ })++}++/*** 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@@@ -206,7 +277,14 @@ function send(res: ServerResponse, type: string, body: string): void {*/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 (++ url !== '/robots.txt' &&++ url !== '/sitemap.xml' &&++ url !== '/llms.txt' &&++ url !== '/llms-full.txt'++ ) {++ return false++ }if (rq.method !== 'GET' && rq.method !== 'HEAD') {res.writeHead(405, { allow: 'GET, HEAD' })res.end()@@@ -226,6 +304,8 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolsend(res, 'application/xml', seo.sitemapXml(publicPages()))return true}-- send(res, 'text/plain', llmsBody())++ // Same type, same cache lifetime as /llms.txt: the full document is a bigger body of the same++ // site description, so it ages on the same clock.++ send(res, 'text/plain', url === '/llms-full.txt' ? llmsFullBody() : llmsBody())return true}@@@ -31,6 +31,7 @@ import { resolveClientAddress } from './clientAddress.js'import { sendNotFound, type NotFoundLink } from './notFound.js'import { discoveryHandler, homeJsonLdTags } from './discovery.js'import { legalHandler } from './legalPages.js'++import { indexnowHandler, type SharedIndexNow } from './indexnow.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). */@@@ -165,6 +166,7 @@ export function createHttpServer(oauth?: OAuthConfig | null,platform?: HttpPlatformHooks,tickets?: WsTicketStore,++ indexnow?: SharedIndexNow,): Server {const root = staticDir ? resolve(staticDir) : null// Sessions the Network confirmed recently: lets a silent login on a signed-in@@@ -184,6 +186,9 @@ export function createHttpServer(]// The site's own /terms, /privacy and /dmca (openvibe-shared/legal), built once for the process.const serveLegal = legalHandler()++ // GET /<key>.txt — the IndexNow key file, before the static files so it is never shadowed by a++ // build artifact of the same name. Absent when no key is configured.++ const serveIndexNow = indexnow ? indexnowHandler(indexnow) : nullreturn createServer((req: IncomingMessage, res: ServerResponse) => {const url = (req.url ?? '/').split('?')[0] ?? '/'// Host-based routing: play.openvibe.games serves the game at its root@@@ -216,7 +221,10 @@ 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.++ // The IndexNow key file (only when a key is configured) — before the static files below.++ if (serveIndexNow?.(req, res)) return++ // Crawl artifacts (plan T11): /robots.txt, /sitemap.xml, /llms.txt and /llms-full.txt, built by++ // openvibe-shared/seo.if (discoveryHandler(req, res)) return// The site's own legal pages (plan T11 decision 6): /terms, /privacy, /dmca. Before the SPA and the// static fallback so they never resolve to a file or the landing page.nnew file mode 100644@@@ -0,0 +1,54 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import { createRequire } from 'node:module'++import type { Logger } from '@openvibe/shared'++++/**++ * IndexNow (openvibe-shared/indexnow): the protocol by which a site tells search engines a URL++ * changed in one POST. Mounted once at boot; when INDEXNOW_KEY is unset nothing is served and++ * nothing is sent.++ *++ * There are no user-created public pages here — the portal and the game page are the site's only++ * public pages and they change with a deploy, not with a publish — so this site pings nothing.++ * What it does serve is the key file the protocol requires engines to fetch before trusting a++ * batch, so a future publish path can ping without touching this wiring.++ */++++export interface SharedIndexNow {++ keyFile(req: IncomingMessage, res: ServerResponse, next: () => void): unknown++ enabled: boolean++}++interface IndexNowFactory {++ createIndexNow(o: { host: string; key: string | null; log: Logger }): SharedIndexNow++}++const shared = createRequire(import.meta.url)('openvibe-shared/indexnow') as IndexNowFactory++++/**++ * The IndexNow instance for this site. An unset or blank key leaves it disabled, which is the++ * whole "off" state: `enabled` is false and the key file is never mounted.++ */++export function createGamesIndexNow(host: string, key: string, log: Logger): SharedIndexNow {++ const trimmed = String(key ?? '').trim()++ // createIndexNow() itself refuses a key that is not 8–128 hex/alphanumeric; an unset key is the++ // supported "off" case and is passed through as null.++ return shared.createIndexNow({ host, key: trimmed === '' ? null : trimmed, log })++}++++/**++ * Answers GET /<key>.txt — the IndexNow key file, which serves itself — for a site with a key.++ * Returns true when it handled the request. With no key nothing is mounted, so the request falls++ * through to the ordinary 404 rather than leaking whether a key is configured.++ */++export function indexnowHandler(++ indexnow: SharedIndexNow,++): (rq: IncomingMessage, res: ServerResponse) => boolean {++ return (rq, res) => {++ if (!indexnow.enabled) return false++ // The shared key file is itself a middleware: it calls next() for any URL that is not its own++ // key file. Anything it did not claim is this server's to route, so hand it back to the chain.++ let fellThrough = false++ indexnow.keyFile(rq, res, () => {++ fellThrough = true++ })++ return !fellThrough++ }++}@@@ -118,8 +118,8 @@ importers:specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1(@electric-sql/[email protected])([email protected])([email protected])openvibe-shared:-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0-- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0++ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0pg:specifier: ^8.23.0version: 8.23.0@@@ -1352,9 +1352,10 @@ packages:pg:optional: true-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0}-- version: 2.2.0++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0:++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0}++ version: 2.6.0++ hasBin: trueresolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}@@@ -2816,7 +2817,7 @@ snapshots:iovalkey: 0.4.0pg: 8.23.0-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0:dependencies:jsonwebtoken: 9.0.3
@@ -24,3 +24,9 @@ HOST=0.0.0.0
@@ -24,3 +24,9 @@ HOST=0.0.0.0
# comma-separated IP/CIDR list. Production: 127.0.0.1 (the local nginx, which overwrites those headers).
# comma-separated IP/CIDR list. Production: 127.0.0.1 (the local nginx, which overwrites those headers).
TRUST_PROXY=
TRUST_PROXY=
MAP_PATH=data/map.json
MAP_PATH=data/map.json
+
+# ── IndexNow (optional) ──
+# The IndexNow protocol key (8-128 hex or alphanumeric), as IndexNow's own key generator produces it.
+# When set, GET /<key>.txt serves the key file the protocol requires engines to fetch; unset means
+# off. This site has no user-created public pages, so a key alone pings nothing.
+INDEXNOW_KEY=
@@ -32,7 +32,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
@@ -32,7 +32,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
-- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release
+- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v2.6.0 (pinned by release
tarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite in
tarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite in
development), Valkey (`iovalkey`, optional)
development), Valkey (`iovalkey`, optional)
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"port": 8000,
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
"domain": "openvibe.games, play.openvibe.games",
- "contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",
+ "contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v2.6.0 (apps/server/package.json)",
"capabilities": [
"capabilities": [
"games.mod.manage",
"games.mod.manage",
"games.mod.read",
"games.mod.read",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.78.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.78.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.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",
@@ -68,3 +68,12 @@ describe('loadConfig scriptMods', () => {
@@ -68,3 +68,12 @@ describe('loadConfig scriptMods', () => {
expect(loadConfig({ GAMES_SCRIPT_MODS: 'true' }).scriptMods).toBe(true)
expect(loadConfig({ GAMES_SCRIPT_MODS: 'true' }).scriptMods).toBe(true)
})
})
})
})
+
+describe('loadConfig indexnowKey', () => {
+ it('is empty (IndexNow off) unless INDEXNOW_KEY is set, and never carries padding', () => {
+ expect(loadConfig({}).indexnowKey).toBe('')
+ expect(loadConfig({ INDEXNOW_KEY: '' }).indexnowKey).toBe('')
+ expect(loadConfig({ INDEXNOW_KEY: ' ' }).indexnowKey).toBe('')
+ expect(loadConfig({ INDEXNOW_KEY: ' testkey0testkey0 ' }).indexnowKey).toBe('testkey0testkey0')
+ })
+})
@@ -68,6 +68,12 @@ export interface ServerConfig {
@@ -68,6 +68,12 @@ export interface ServerConfig {
* place. Off by default; the mods stay in the def set (and its digest) either way.
* place. Off by default; the mods stay in the def set (and its digest) either way.
*/
*/
scriptMods: boolean
scriptMods: boolean
+ /**
+ * INDEXNOW_KEY: the IndexNow protocol key (8–128 hex or alphanumeric). When set, the key file is
+ * served at /<key>.txt so engines can verify it. Empty: off — nothing is mounted and nothing is
+ * sent.
+ */
+ indexnowKey: string
}
}
export interface PlatformConfig {
export interface PlatformConfig {
@@ -153,6 +159,8 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -153,6 +159,8 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
}
}
: null,
: null,
platform: loadPlatformConfig(env),
platform: loadPlatformConfig(env),
+ /** IndexNow is off until a key is configured; an empty key never mounts the key file. */
+ indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),
tickRate: 30,
tickRate: 30,
snapshotEvery: 2,
snapshotEvery: 2,
interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
@@ -20,6 +20,7 @@ import {
@@ -20,6 +20,7 @@ import {
SOCKETS_MS,
SOCKETS_MS,
within,
within,
} from './net/gracefulStop.js'
} from './net/gracefulStop.js'
+import { createGamesIndexNow } from './net/indexnow.js'
import { createHttpServer } from './net/httpServer.js'
import { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { acceptUpgrade, attachWebSocket } from './net/wsTransport.js'
import { acceptUpgrade, attachWebSocket } from './net/wsTransport.js'
@@ -422,6 +423,11 @@ async function main(): Promise<void> {
@@ -422,6 +423,11 @@ async function main(): Promise<void> {
clientAddress,
clientAddress,
},
},
ticketStore,
ticketStore,
+ createGamesIndexNow(
+ config.oauth?.selfUrl ?? 'https://openvibe.games',
+ config.indexnowKey,
+ log.child({ system: 'indexnow' }),
+ ),
)
)
const drainer = httpDrainer(http)
const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapNodes()
@@ -6,19 +6,24 @@ import { dirname, join } from 'node:path'
@@ -6,19 +6,24 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { fileURLToPath } from 'node:url'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { loadConfig } from '../config.js'
import { ServerMetrics } from '../observability/metrics.js'
import { ServerMetrics } from '../observability/metrics.js'
import { createHttpServer } from './httpServer.js'
import { createHttpServer } from './httpServer.js'
+import { createGamesIndexNow } from './indexnow.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.
+ * The crawl artifacts of plan T11 (audit §4 items 1-2): /robots.txt, /sitemap.xml, /llms.txt and
+ * /llms-full.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. The IndexNow key file is exercised here too: it is the other root-level text artifact
+ * this layer serves, and its on/off state is a configuration difference rather than a route.
*/
*/
const log = createConsoleLogger({ app: 'test' }, 'error')
const log = createConsoleLogger({ app: 'test' }, 'error')
const APEX = 'openvibe.games'
const APEX = 'openvibe.games'
const PRIVATE = ['/auth/', '/api/', '/metrics', '/editor']
const PRIVATE = ['/auth/', '/api/', '/metrics', '/editor']
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..')
+const INDEXNOW_KEY = 'testkey0testkey0'
interface Reply {
interface Reply {
status: number
status: number
@@ -28,7 +33,9 @@ interface Reply {
@@ -28,7 +33,9 @@ interface Reply {
let dir: string
let dir: string
let server: Server
let server: Server
+let keyedServer: Server
let port = 0
let port = 0
+let keyedPort = 0
function listen(s: Server): Promise<number> {
function listen(s: Server): Promise<number> {
return new Promise((resolve) =>
return new Promise((resolve) =>
@@ -38,13 +45,13 @@ function listen(s: Server): Promise<number> {
@@ -38,13 +45,13 @@ function listen(s: Server): Promise<number> {
function send(
function send(
path: string,
path: string,
- opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders } = {},
+ opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders; port?: number } = {},
): Promise<Reply> {
): Promise<Reply> {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const req = request(
const req = request(
{
{
host: '127.0.0.1',
host: '127.0.0.1',
- port,
+ port: opts.port ?? port,
path,
path,
method: opts.method ?? 'GET',
method: opts.method ?? 'GET',
headers: { host: opts.host ?? APEX, ...opts.headers },
headers: { host: opts.host ?? APEX, ...opts.headers },
@@ -79,12 +86,43 @@ beforeAll(async () => {
@@ -79,12 +86,43 @@ beforeAll(async () => {
join(dist, 'play.html'),
join(dist, 'play.html'),
'<!doctype html><html><head><title>game page</title></head><body>game</body></html>',
'<!doctype html><html><head><title>game page</title></head><body>game</body></html>',
)
)
- server = createHttpServer(dist, new ServerMetrics(), log)
+ // Two instances of the same server over the same build: one with no IndexNow key (off) and one
+ // with a key (on), so the difference is the configuration and nothing else.
+ server = createHttpServer(
+ dist,
+ new ServerMetrics(),
+ log,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ // No INDEXNOW_KEY: the same wiring as main.ts, with the key unset — the whole "off" state.
+ createGamesIndexNow('https://openvibe.games', loadConfig({}).indexnowKey, log),
+ )
port = await listen(server)
port = await listen(server)
+ keyedServer = createHttpServer(
+ dist,
+ new ServerMetrics(),
+ log,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ // The key comes from the config, exactly as main.ts reads it: INDEXNOW_KEY, trimmed.
+ createGamesIndexNow('https://openvibe.games', loadConfig({ INDEXNOW_KEY }).indexnowKey, log),
+ )
+ keyedPort = await listen(keyedServer)
})
})
afterAll(() => {
afterAll(() => {
server.close()
server.close()
+ keyedServer.close()
rmSync(dir, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
})
})
@@ -131,6 +169,38 @@ describe('crawl artifacts (plan T11)', () => {
@@ -131,6 +169,38 @@ describe('crawl artifacts (plan T11)', () => {
for (const p of PRIVATE) expect(r.body).not.toContain(p)
for (const p of PRIVATE) expect(r.body).not.toContain(p)
})
})
+ it('serves /llms-full.txt: text/plain, the home page, and nothing robots keeps out', async () => {
+ const r = await send('/llms-full.txt')
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')
+ // The same cache lifetime as /llms.txt: the same site description, as one document.
+ expect(r.headers['cache-control']).toBe('public, max-age=3600')
+ // The home URL, and the site's own header and summary, exactly as /llms.txt opens with.
+ expect(r.body).toContain('https://openvibe.games/')
+ expect(r.body).toContain('# OpenVibe.Games')
+ const index = await send('/llms.txt')
+ const summary = /(^|> )(OpenVibe\.Games: the games arm.*)$/m.exec(index.body)?.[1]
+ expect(summary).toBeTruthy()
+ expect(r.body).toContain(summary as string)
+ // Every public page /llms.txt lists appears, with its text.
+ expect(r.body).toContain('https://play.openvibe.games/')
+ // Public pages only: no path from the robots Disallow list, and no per-user or editor surface.
+ for (const p of PRIVATE) expect(r.body).not.toContain(p)
+ })
+
+ it('serves the IndexNow key file when INDEXNOW_KEY is set, and 404 when it is not', async () => {
+ const on = await send(`/${INDEXNOW_KEY}.txt`, { port: keyedPort })
+ expect(on.status).toBe(200)
+ expect(on.headers['content-type']).toBe('text/plain; charset=utf-8')
+ expect(on.body).toBe(INDEXNOW_KEY)
+ // Unset: nothing is mounted, so the path is an ordinary 404 — it never says a key is missing.
+ const off = await send(`/${INDEXNOW_KEY}.txt`)
+ expect(off.status).toBe(404)
+ // Any other .txt is untouched on a keyed instance: the key file serves only its own name.
+ const other = await send('/someother.txt', { port: keyedPort })
+ expect(other.status).toBe(404)
+ })
+
it('injects the home page JSON-LD built by openvibe-shared/seo: WebSite + the primary type', async () => {
it('injects the home page JSON-LD built by openvibe-shared/seo: WebSite + the primary type', async () => {
const r = await send('/')
const r = await send('/')
expect(r.status).toBe(200)
expect(r.status).toBe(200)
@@ -6,9 +6,9 @@ import { fileURLToPath } from 'node:url'
@@ -6,9 +6,9 @@ import { fileURLToPath } from 'node:url'
/**
/**
* Crawl and machine-readability artifacts (plan T11; audit §4 items 1-2): GET /robots.txt,
* 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.
+ * GET /sitemap.xml, GET /llms.txt and GET /llms-full.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
* 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
* per-user API, operational metrics and the noindex editor app are absent and Disallowed in
@@ -38,8 +38,27 @@ interface SeoUrl {
@@ -38,8 +38,27 @@ interface SeoUrl {
changefreq?: string
changefreq?: string
priority?: number
priority?: number
}
}
+interface LlmsFullPage {
+ title: string
+ url: string
+ /** The page's own text; here the site's own description of it, never scraped HTML. */
+ text?: string
+}
+interface LlmsFullSection {
+ title: string
+ pages: LlmsFullPage[]
+}
+interface LlmsFullOptions {
+ /** The site name and base URL: the document opens `# <name>`, and page URLs resolve against it. */
+ site: { name: string; url: string }
+ summary: string
+ sections?: LlmsFullSection[]
+ /** Hard byte ceiling: a page that would cross it is dropped, never truncated mid-word. */
+ maxBytes?: number
+}
interface SeoToolkit {
interface SeoToolkit {
llmsTxt(o: LlmsOptions): string
llmsTxt(o: LlmsOptions): string
+ llmsFull(o: LlmsFullOptions): string
robotsTxt(o: { sitemaps?: string[]; disallow?: string[]; allow?: string[] }): string
robotsTxt(o: { sitemaps?: string[]; disallow?: string[]; allow?: string[] }): string
sitemapXml(urls: SeoUrl[]): string
sitemapXml(urls: SeoUrl[]): string
jsonLd: {
jsonLd: {
@@ -70,6 +89,12 @@ const DESCRIPTION =
@@ -70,6 +89,12 @@ 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.'
'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 =
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.'
'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.'
+// The one summary and one detail paragraph /llms.txt and /llms-full.txt both open with: a model
+// reading the full document must meet the same description of the site as one reading the index.
+const SITE_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.'
+const SITE_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.'
// Every path robots.txt keeps out of crawlers. None is a public page, so none is in the sitemap
// 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.
// or llms.txt either: sign-in, the per-user API, operational metrics and the noindex editor app.
const DISALLOW = ['/auth/', '/api/', '/metrics', '/editor']
const DISALLOW = ['/auth/', '/api/', '/metrics', '/editor']
@@ -109,32 +134,51 @@ function publicPages(): SeoUrl[] {
@@ -109,32 +134,51 @@ function publicPages(): SeoUrl[] {
priority,
priority,
...(lastmod ? { lastmod } : {}),
...(lastmod ? { lastmod } : {}),
})
})
- return [at(`${SITE}/`, 'weekly', 1.0), at(`${PLAY}/`, 'weekly', 0.9)]
+ return publicPageDocs().map((p) => at(p.url, 'weekly', p.priority))
+}
+
+/**
+ * The site's public pages, once, as the data both artifacts read: llms.txt lists them with a note,
+ * llms-full.txt carries the same pages with their text. One source, so the two can never disagree
+ * about what is public. There are no user-created public pages here — the world is a game document
+ * served at /map.json, not a page — so this list is fixed.
+ */
+interface PublicPageDoc {
+ title: string
+ url: string
+ note: string
+ text: string
+ priority: number
+}
+function publicPageDocs(): PublicPageDoc[] {
+ return [
+ {
+ title: 'OpenVibe.Games home',
+ url: `${SITE}/`,
+ note: 'the portal: what there is to play, and what signing in adds',
+ text: DESCRIPTION,
+ priority: 1.0,
+ },
+ {
+ title: 'Scraplandia',
+ url: `${PLAY}/`,
+ note: 'the live multiplayer game, playable as a guest in the browser',
+ text: GAME_DESCRIPTION,
+ priority: 0.9,
+ },
+ ]
}
}
/** The /llms.txt body: what the site is, its public pages and its machine-readable endpoints. */
/** The /llms.txt body: what the site is, its public pages and its machine-readable endpoints. */
function llmsBody(): string {
function llmsBody(): string {
return seo.llmsTxt({
return seo.llmsTxt({
name: SITE_NAME,
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.',
+ summary: SITE_SUMMARY,
+ details: SITE_DETAILS,
sections: [
sections: [
{
{
title: 'Public pages',
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',
- },
- ],
+ links: publicPageDocs().map((p) => ({ title: p.title, url: p.url, note: p.note })),
},
},
{
{
title: 'Machine-readable',
title: 'Machine-readable',
@@ -149,6 +193,11 @@ function llmsBody(): string {
@@ -149,6 +193,11 @@ function llmsBody(): string {
url: `${SITE}/robots.txt`,
url: `${SITE}/robots.txt`,
note: 'search and AI crawlers are welcome on the public pages',
note: 'search and AI crawlers are welcome on the public pages',
},
},
+ {
+ title: 'Full text (llms-full.txt)',
+ url: `${SITE}/llms-full.txt`,
+ note: 'this site’s public pages as one text document, for a model to read whole',
+ },
{
{
title: 'World map (JSON)',
title: 'World map (JSON)',
url: `${SITE}/map.json`,
url: `${SITE}/map.json`,
@@ -160,6 +209,28 @@ function llmsBody(): string {
@@ -160,6 +209,28 @@ function llmsBody(): string {
})
})
}
}
+/**
+ * The /llms-full.txt body: the same site header and summary as /llms.txt, then every public page
+ * llms.txt lists with its own text — the two descriptions the site's JSON-LD already publishes,
+ * which are the site's own words for those pages rather than scraped markup. Public pages only, so
+ * nothing under robots' Disallow can reach a model, and a 512 KB ceiling keeps the answer a
+ * document rather than a payload.
+ */
+const LLMS_FULL_MAX_BYTES = 512 * 1024
+function llmsFullBody(): string {
+ return seo.llmsFull({
+ site: { name: SITE_NAME, url: SITE },
+ summary: SITE_SUMMARY,
+ sections: [
+ {
+ title: 'Public pages',
+ pages: publicPageDocs().map((p) => ({ title: p.title, url: p.url, text: p.text })),
+ },
+ ],
+ maxBytes: LLMS_FULL_MAX_BYTES,
+ })
+}
+
/**
/**
* The home page's JSON-LD: the site as a WebSite, the game as the site's primary type (the shared
* 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
* kit's WebApplication, category GameApplication) and the portal page that carries them. The same
@@ -206,7 +277,14 @@ function send(res: ServerResponse, type: string, body: string): void {
@@ -206,7 +277,14 @@ function send(res: ServerResponse, type: string, body: string): void {
*/
*/
export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolean {
export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolean {
const url = (rq.url ?? '/').split('?')[0] ?? '/'
const url = (rq.url ?? '/').split('?')[0] ?? '/'
- if (url !== '/robots.txt' && url !== '/sitemap.xml' && url !== '/llms.txt') return false
+ if (
+ url !== '/robots.txt' &&
+ url !== '/sitemap.xml' &&
+ url !== '/llms.txt' &&
+ url !== '/llms-full.txt'
+ ) {
+ return false
+ }
if (rq.method !== 'GET' && rq.method !== 'HEAD') {
if (rq.method !== 'GET' && rq.method !== 'HEAD') {
res.writeHead(405, { allow: 'GET, HEAD' })
res.writeHead(405, { allow: 'GET, HEAD' })
res.end()
res.end()
@@ -226,6 +304,8 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
@@ -226,6 +304,8 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
send(res, 'application/xml', seo.sitemapXml(publicPages()))
send(res, 'application/xml', seo.sitemapXml(publicPages()))
return true
return true
}
}
- send(res, 'text/plain', llmsBody())
+ // Same type, same cache lifetime as /llms.txt: the full document is a bigger body of the same
+ // site description, so it ages on the same clock.
+ send(res, 'text/plain', url === '/llms-full.txt' ? llmsFullBody() : llmsBody())
return true
return true
}
}
@@ -31,6 +31,7 @@ import { resolveClientAddress } from './clientAddress.js'
@@ -31,6 +31,7 @@ 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'
import { discoveryHandler, homeJsonLdTags } from './discovery.js'
import { legalHandler } from './legalPages.js'
import { legalHandler } from './legalPages.js'
+import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
/** 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). */
* per request (a new build changes the mtime and rebuilds it once). */
@@ -165,6 +166,7 @@ export function createHttpServer(
@@ -165,6 +166,7 @@ export function createHttpServer(
oauth?: OAuthConfig | null,
oauth?: OAuthConfig | null,
platform?: HttpPlatformHooks,
platform?: HttpPlatformHooks,
tickets?: WsTicketStore,
tickets?: WsTicketStore,
+ indexnow?: SharedIndexNow,
): Server {
): Server {
const root = staticDir ? resolve(staticDir) : null
const root = staticDir ? resolve(staticDir) : null
// 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
@@ -184,6 +186,9 @@ export function createHttpServer(
@@ -184,6 +186,9 @@ export function createHttpServer(
]
]
// The site's own /terms, /privacy and /dmca (openvibe-shared/legal), built once for the process.
// The site's own /terms, /privacy and /dmca (openvibe-shared/legal), built once for the process.
const serveLegal = legalHandler()
const serveLegal = legalHandler()
+ // GET /<key>.txt — the IndexNow key file, before the static files so it is never shadowed by a
+ // build artifact of the same name. Absent when no key is configured.
+ const serveIndexNow = indexnow ? indexnowHandler(indexnow) : null
return createServer((req: IncomingMessage, res: ServerResponse) => {
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = (req.url ?? '/').split('?')[0] ?? '/'
const url = (req.url ?? '/').split('?')[0] ?? '/'
// Host-based routing: play.openvibe.games serves the game at its root
// Host-based routing: play.openvibe.games serves the game at its root
@@ -216,7 +221,10 @@ export function createHttpServer(
@@ -216,7 +221,10 @@ 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.
+ // The IndexNow key file (only when a key is configured) — before the static files below.
+ if (serveIndexNow?.(req, res)) return
+ // Crawl artifacts (plan T11): /robots.txt, /sitemap.xml, /llms.txt and /llms-full.txt, built by
+ // openvibe-shared/seo.
if (discoveryHandler(req, res)) return
if (discoveryHandler(req, res)) return
// The site's own legal pages (plan T11 decision 6): /terms, /privacy, /dmca. Before the SPA and the
// The site's own legal pages (plan T11 decision 6): /terms, /privacy, /dmca. Before the SPA and the
// static fallback so they never resolve to a file or the landing page.
// static fallback so they never resolve to a file or the landing page.
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createRequire } from 'node:module'
+import type { Logger } from '@openvibe/shared'
+
+/**
+ * IndexNow (openvibe-shared/indexnow): the protocol by which a site tells search engines a URL
+ * changed in one POST. Mounted once at boot; when INDEXNOW_KEY is unset nothing is served and
+ * nothing is sent.
+ *
+ * There are no user-created public pages here — the portal and the game page are the site's only
+ * public pages and they change with a deploy, not with a publish — so this site pings nothing.
+ * What it does serve is the key file the protocol requires engines to fetch before trusting a
+ * batch, so a future publish path can ping without touching this wiring.
+ */
+
+export interface SharedIndexNow {
+ keyFile(req: IncomingMessage, res: ServerResponse, next: () => void): unknown
+ enabled: boolean
+}
+interface IndexNowFactory {
+ createIndexNow(o: { host: string; key: string | null; log: Logger }): SharedIndexNow
+}
+const shared = createRequire(import.meta.url)('openvibe-shared/indexnow') as IndexNowFactory
+
+/**
+ * The IndexNow instance for this site. An unset or blank key leaves it disabled, which is the
+ * whole "off" state: `enabled` is false and the key file is never mounted.
+ */
+export function createGamesIndexNow(host: string, key: string, log: Logger): SharedIndexNow {
+ const trimmed = String(key ?? '').trim()
+ // createIndexNow() itself refuses a key that is not 8–128 hex/alphanumeric; an unset key is the
+ // supported "off" case and is passed through as null.
+ return shared.createIndexNow({ host, key: trimmed === '' ? null : trimmed, log })
+}
+
+/**
+ * Answers GET /<key>.txt — the IndexNow key file, which serves itself — for a site with a key.
+ * Returns true when it handled the request. With no key nothing is mounted, so the request falls
+ * through to the ordinary 404 rather than leaking whether a key is configured.
+ */
+export function indexnowHandler(
+ indexnow: SharedIndexNow,
+): (rq: IncomingMessage, res: ServerResponse) => boolean {
+ return (rq, res) => {
+ if (!indexnow.enabled) return false
+ // The shared key file is itself a middleware: it calls next() for any URL that is not its own
+ // key file. Anything it did not claim is this server's to route, so hand it back to the chain.
+ let fellThrough = false
+ indexnow.keyFile(rq, res, () => {
+ fellThrough = true
+ })
+ return !fellThrough
+ }
+}
@@ -118,8 +118,8 @@ importers:
@@ -118,8 +118,8 @@ importers:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1(@electric-sql/[email protected])([email protected])([email protected])
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1(@electric-sql/[email protected])([email protected])([email protected])
openvibe-shared:
openvibe-shared:
- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0
- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0
+ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0
pg:
pg:
specifier: ^8.23.0
specifier: ^8.23.0
version: 8.23.0
version: 8.23.0
@@ -1352,9 +1352,10 @@ packages:
@@ -1352,9 +1352,10 @@ packages:
pg:
pg:
optional: true
optional: true
- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:
- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0}
- version: 2.2.0
+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0:
+ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0}
+ version: 2.6.0
+ hasBin: true
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
@@ -2816,7 +2817,7 @@ snapshots:
@@ -2816,7 +2817,7 @@ 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/v2.2.0:
+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0:
dependencies:
dependencies:
jsonwebtoken: 9.0.3
jsonwebtoken: 9.0.3