openvibers/openvibe.games Commits
Unknown paths answer 404 instead of the landing page: a path that was neither a route nor a file of the client build used to get the portal (openvibe.games) or the game (play.openvibe.games) with a 200, a soft 404 that search engines index and monitors read as up. It now gets a real 404 with Cache-Control: no-store. Browsers get a small self-contained HTML page (inline CSS, no scripts) that shows the requested path escaped and links to the host's entry points (apex: /, /play, /editor; play host: /, /editor and the portal). Anything under /api, and a client that asks for JSON, gets {"error":"not_found"}, the shape /auth/* already answers. A missing asset (any extension but .html, e.g. /favicon.ico, /robots.txt, a stale /assets/*.js) gets plain text. HEAD and POST get the same status. Real routes keep their status: / (portal on the apex, the game on the play host), /play (301 to / on the play host), /editor, the build's files including the hashed /assets, /map.json, /map-assets/<id>, the API (/api/characters, /api/map, /api/map-assets, /api/texture, /api/ready, /api/v1/platform, /api/v1/mods), /auth/*, /healthz and /metrics; /play/ and /editor/ now redirect to their canonical URL, and /game and /canvas (the URLs from when the game lived on OpenVibe.Live, which still links and 301s to them) redirect to /play and / (both to / on the play host) instead of becoming 404s. The client has no router of its own, so no page prefix accepts sub-paths. apps/server/src/net/httpServer.test.ts drives the real HTTP layer over a stand-in build on both hosts
Changes
@@@ -83,7 +83,11 @@ STATIC_DIR=apps/client/dist PORT=8000 DB_PATH=data/world.db pnpm --filter @openv```The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game--WebSocket on one port. `/api/ready` is 200 only while `world.db` answers a query and the simulation++WebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` is++the game and `/play` redirects there. `/game` and `/canvas` (the URLs from when the game lived on++OpenVibe.Live) redirect to `/play` and `/`. Any other path that is neither a file of the build nor a route++answers 404 (an HTML page with links to those pages; `{"error":"not_found"}` under `/api`; plain text++for a missing asset), never the portal with a 200. `/api/ready` is 200 only while `world.db` answers a query and the simulationhas ticked within the last 5 s, otherwise 503 naming the failed check (`status``ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `DB_PATH`, `STATIC_DIR`,`MAX_PLAYERS`, `LOG_LEVEL` (platform variables below).@@@ -1 +1 @@--{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAsBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAyBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CAyhBR"}\\ No newline at end of file++{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAuBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AA2BlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CAyjBR"}\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,2 @@++export {};++//# sourceMappingURL=httpServer.test.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -0,0 +1 @@++{"version":3,"file":"httpServer.test.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.test.ts"],"names":[],"mappings":""}\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,23 @@++import type { IncomingMessage, ServerResponse } from 'node:http';++/**++ * The answer for a path no route matches: a real 404, never the landing page++ * with a 200 (a "soft 404" that search engines index as a duplicate of the++ * portal and monitors read as up).++ *++ * Page requests get a small self-contained HTML page that names the path and++ * links to the host's real entry points. API paths and clients asking for++ * JSON get `{"error":"not_found"}` (the shape /auth/* already answers). A++ * missing asset (any extension but .html) gets plain text.++ */++export interface NotFoundLink {++ href: string;++ label: string;++}++export type NotFoundKind = 'html' | 'json' | 'text';++export declare function escapeHtml(value: string): string;++/** Which body a 404 for `path` gets. */++export declare function notFoundKind(req: IncomingMessage, path: string): NotFoundKind;++export declare function notFoundPage(path: string, links: readonly NotFoundLink[]): string;++/** Answers 404 for `path` with the body its client can use; HEAD gets the headers only. */++export declare function sendNotFound(req: IncomingMessage, res: ServerResponse, path: string, links: readonly NotFoundLink[]): void;++//# sourceMappingURL=notFound.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -0,0 +1 @@++{"version":3,"file":"notFound.d.ts","sourceRoot":"","sources":["../../src/net/notFound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAGhE;;;;;;;;;GASG;AAEH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;AAUnD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,wCAAwC;AACxC,wBAAgB,YAAY,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAO7E;AAKD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAuCjF;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAC1B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,SAAS,YAAY,EAAE,GAC7B,IAAI,CAmBN"}\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,303 @@++import { mkdirSync, mkdtempSync, 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 { join } from 'node:path'++import { createConsoleLogger } from '@openvibe/shared'++import { afterAll, beforeAll, describe, expect, it } from 'vitest'++import { ServerMetrics } from '../observability/metrics.js'++import { createReadiness } from '../observability/readiness.js'++import { createHttpServer } from './httpServer.js'++import { escapeHtml, notFoundPage } from './notFound.js'++++/**++ * Routing of the real HTTP layer over a stand-in client build: every real++ * route keeps its status, the play host serves the game at /, and a path no++ * route matches is a 404 (never the landing page with a 200).++ */++++const log = createConsoleLogger({ app: 'test' }, 'error')++const APEX = 'openvibe.games'++const PLAY = 'play.openvibe.games'++const ASSET = '/assets/main-Cj5pXm1a.js'++++interface Reply {++ status: number++ headers: IncomingHttpHeaders++ body: string++}++++let dir: string++let server: Server++let bare: Server++++function listen(s: Server): Promise<number> {++ return new Promise((resolve) =>++ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),++ )++}++++let port = 0++let barePort = 0++++/** A raw request (node:http, so the Host header and the path go out exactly as given). */++function send(++ path: string,++ opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders; to?: number } = {},++): Promise<Reply> {++ return new Promise((resolve, reject) => {++ const req = request(++ {++ host: '127.0.0.1',++ port: opts.to ?? 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-http-'))++ const dist = join(dir, 'dist')++ mkdirSync(join(dist, 'assets', 'tex'), { recursive: true })++ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')++ writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')++ writeFileSync(join(dist, 'editor.html'), '<!doctype html><title>editor page</title>')++ writeFileSync(join(dist, 'chrome.js'), 'void 0')++ writeFileSync(join(dist, ASSET.slice(1)), 'export {}')++ writeFileSync(join(dist, 'assets', 'tex', 'wood_planks.jpg'), 'jpeg')++ const mapPath = join(dir, 'data', 'map.json')++ mkdirSync(join(dir, 'data', 'map-assets'), { recursive: true })++ writeFileSync(join(dir, 'data', 'map-assets', 'tex-1700000000000.png'), 'png')++++ const metrics = new ServerMetrics()++ metrics.tick = 42++ metrics.lastTickAt = Date.now()++ // /api/ready arrives through the platform hook, as main.ts wires it.++ const readiness = createReadiness({ pingDb: () => true, metrics })++ server = createHttpServer(++ dist,++ metrics,++ log,++ mapPath,++ { key: 'editor-secret', networkAuthUrl: null },++ undefined,++ async () => [{ slot: 0, name: 'Ana', appearance: null }],++ null,++ { handle: (req, res) => readiness.handle(req, res) },++ )++ bare = createHttpServer(null, metrics, log)++ port = await listen(server)++ barePort = await listen(bare)++})++++afterAll(() => {++ server.close()++ bare.close()++ rmSync(dir, { recursive: true, force: true })++})++++describe('real routes on the apex', () => {++ it.each([++ ['/', 'portal page'],++ ['/play', 'game page'],++ ['/play?sso=none', 'game page'],++ ['/editor', 'editor page'],++ ['/index.html', 'portal page'],++ ])('%s answers 200 with its page', async (path, marker) => {++ const r = await send(path)++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')++ expect(r.headers['cache-control']).toBe('no-cache')++ expect(r.body).toContain(marker)++ })++++ it.each([++ ['/game', '/play'],++ ['/canvas', '/'],++ ])('%s (the old OpenVibe.Live URL) redirects to %s', async (path, location) => {++ const r = await send(path)++ expect(r.status).toBe(301)++ expect(r.headers.location).toBe(location)++ })++++ it('serves hashed build assets as immutable, and public files', async () => {++ const asset = await send(ASSET)++ expect(asset.status).toBe(200)++ expect(asset.headers['content-type']).toBe('text/javascript')++ expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable')++ expect((await send('/chrome.js')).status).toBe(200)++ expect((await send('/assets/tex/wood_planks.jpg')).status).toBe(200)++ })++++ it('answers health, readiness, metrics and the API', async () => {++ const health = await send('/healthz')++ expect(health.status).toBe(200)++ expect(JSON.parse(health.body)).toEqual({ ok: true, tick: 42 })++ expect((await send('/api/ready')).status).toBe(200)++ expect((await send('/metrics')).status).toBe(200)++ const chars = await send('/api/characters?token=guest-token-1234')++ expect(chars.status).toBe(200)++ expect(JSON.parse(chars.body)).toEqual([{ slot: 0, name: 'Ana', appearance: null }])++ const map = await send('/map.json')++ expect(map.status).toBe(200)++ expect(map.headers.etag).toBeTruthy()++ const tex = await send('/map-assets/tex-1700000000000.png')++ expect(tex.status).toBe(200)++ expect(tex.headers['content-type']).toBe('image/png')++ })++++ it('answers the SSO routes', async () => {++ const me = await send('/auth/me')++ expect(me.status).toBe(200)++ expect(JSON.parse(me.body)).toEqual({ user: null })++ const out = await send('/auth/logout')++ expect(out.status).toBe(200)++ expect(out.body).toContain('location.replace("/play")')++ })++++ it.each([++ ['/play.html', '/play'],++ ['/editor.html', '/editor'],++ ['/play/', '/play'],++ ['/editor/', '/editor'],++ ])('%s redirects to %s', async (path, location) => {++ const r = await send(path)++ expect(r.status).toBe(301)++ expect(r.headers.location).toBe(location)++ })++++ it('answers HEAD like GET, without a body', async () => {++ const r = await send('/', { method: 'HEAD' })++ expect(r.status).toBe(200)++ expect(r.body).toBe('')++ })++})++++describe('unknown paths answer 404', () => {++ it.each(['/nope', '/play/extra', '/editor/some-map', '/assets', '/assets/', '/old-page.html'])(++ '%s is a 404 page, not the portal',++ async (path) => {++ const r = await send(path)++ expect(r.status).toBe(404)++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')++ expect(r.headers['cache-control']).toBe('no-store')++ expect(r.body).toContain('<title>Page not found · OpenVibe.Games</title>')++ expect(r.body).not.toContain('portal page')++ expect(r.body).toContain('<a href="/">OpenVibe.Games home</a>')++ expect(r.body).toContain('<a href="/play">Play Scraplandia</a>')++ expect(r.body).toContain('<a href="/editor">Map editor</a>')++ expect(r.body).not.toMatch(/<script\b/)++ },++ )++++ it('echoes the requested path escaped', async () => {++ const r = await send(`/x<script>alert("1")</script>&'y`)++ expect(r.status).toBe(404)++ expect(r.body).not.toContain('<script>alert')++ expect(r.body).toContain(++ '<code>/x<script>alert("1")</script>&'y</code>',++ )++ const encoded = await send('/%3Cimg%20src%3Dx%3E')++ expect(encoded.body).toContain('<code>/%3Cimg%20src%3Dx%3E</code>')++ })++++ it('gives HEAD and POST the same status', async () => {++ const head = await send('/nope', { method: 'HEAD' })++ expect(head.status).toBe(404)++ expect(head.headers['content-type']).toBe('text/html; charset=utf-8')++ expect(head.body).toBe('')++ expect((await send('/nope', { method: 'POST' })).status).toBe(404)++ })++++ it('answers a missing asset with a plain-text 404', async () => {++ for (const path of ['/assets/main-Missing1.js', '/favicon.ico', '/assets/tex/nope.jpg']) {++ const r = await send(path)++ expect(r.status).toBe(404)++ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')++ expect(r.body).toBe('not found\n')++ }++ expect((await send('/map-assets/tex-1.png')).status).toBe(404)++ })++++ it('answers unknown API paths and JSON clients with the JSON 404', async () => {++ for (const path of ['/api/nope', '/api', '/api/map', '/auth/nope']) {++ const r = await send(path)++ expect(r.status).toBe(404)++ expect(r.headers['content-type']).toBe('application/json')++ expect(JSON.parse(r.body)).toEqual({ error: 'not_found' })++ }++ const json = await send('/nope', { headers: { accept: 'application/json' } })++ expect(json.status).toBe(404)++ expect(JSON.parse(json.body)).toEqual({ error: 'not_found' })++ })++})++++describe('play.openvibe.games', () => {++ it('serves the game at /', async () => {++ const r = await send('/', { host: PLAY })++ expect(r.status).toBe(200)++ expect(r.body).toContain('game page')++ })++++ it.each(['/play', '/play/', '/play.html', '/game', '/canvas'])(++ '%s redirects to /',++ async (path) => {++ const r = await send(path, { host: PLAY })++ expect(r.status).toBe(301)++ expect(r.headers.location).toBe('/')++ },++ )++++ it('serves the editor, assets, map and health like the apex', async () => {++ expect((await send('/editor', { host: PLAY })).body).toContain('editor page')++ expect((await send(ASSET, { host: PLAY })).status).toBe(200)++ expect((await send('/map.json', { host: PLAY })).status).toBe(200)++ expect((await send('/healthz', { host: PLAY })).status).toBe(200)++ })++++ it('answers an unknown path with 404, not the game', async () => {++ const r = await send('/nope', { host: PLAY })++ expect(r.status).toBe(404)++ expect(r.body).not.toContain('game page')++ expect(r.body).toContain('<a href="/">Play Scraplandia</a>')++ expect(r.body).toContain('<a href="https://openvibe.games/">OpenVibe.Games home</a>')++ expect((await send('/assets/nope.js', { host: PLAY })).status).toBe(404)++ })++})++++describe('without a client build (API/WS only)', () => {++ it('still answers health, and 404 for pages', async () => {++ expect((await send('/healthz', { to: barePort })).status).toBe(200)++ const r = await send('/', { to: barePort })++ expect(r.status).toBe(404)++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')++ })++})++++describe('404 page', () => {++ it('escapes every link and cuts a very long path', () => {++ expect(escapeHtml(`<a href="x">'&'</a>`)).toBe(++ '<a href="x">'&'</a>',++ )++ const page = notFoundPage(`/${'a'.repeat(500)}`, [{ href: '/?a=1&b="2"', label: '<b>' }])++ expect(page).toContain('<a href="/?a=1&b="2""><b></a>')++ expect(page).toContain(`/${'a'.repeat(199)}…</code>`)++ expect(page).not.toContain('a'.repeat(201))++ })++})@@@ -25,14 +25,17 @@ import {} from './sso.js'import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'++import { sendNotFound, type NotFoundLink } from './notFound.js'import type { MapFileV2 } from '@openvibe/content'/*** Minimal HTTP layer: health/metrics endpoints and (in production) the-- * built client bundle. Game traffic itself is WebSocket-only.++ * built client bundle. Game traffic itself is WebSocket-only. A path no++ * route matches is a 404 (see notFound.ts), never the landing page.*/const DEFAULT_NETWORK_URL = 'https://openvibe.network'++const DEFAULT_PORTAL_URL = 'https://openvibe.games'const MIME: Record<string, string> = {'.html': 'text/html; charset=utf-8',@@@ -138,12 +141,25 @@ export function createHttpServer(// Sessions the Network confirmed recently: lets a silent login on a signed-in// browser answer without the Network round trip. See /auth/login.const knownSessions = new SessionCheckCache()++ // The real entry points, offered on the 404 page of each host.++ const portalHome = `${(oauth?.selfUrl ?? DEFAULT_PORTAL_URL).replace(/\/+$/, '')}/`++ const apexLinks: NotFoundLink[] = [++ { href: '/', label: 'OpenVibe.Games home' },++ { href: '/play', label: 'Play Scraplandia' },++ { href: '/editor', label: 'Map editor' },++ ]++ const playLinks: NotFoundLink[] = [++ { href: '/', label: 'Play Scraplandia' },++ { href: '/editor', label: 'Map editor' },++ { href: portalHome, label: 'OpenVibe.Games home' },++ ]return createServer((req: IncomingMessage, res: ServerResponse) => {const url = (req.url ?? '/').split('?')[0] ?? '/'// Host-based routing: play.openvibe.games serves the game at its root// (the apex root is the games portal). Everything else — /assets,// /map.json, /api, /auth, websockets — behaves identically on both.const playHost = (req.headers.host ?? '').toLowerCase().startsWith('play.')++ const notFound = () => sendNotFound(req, res, url, playHost ? playLinks : apexLinks)if (platform?.handle?.(req, res)) returnif (url === '/map.json' && mapPath) {// The canonical v2 document, with its revision as an ETag. This IS the@@@ -603,15 +619,19 @@ export function createHttpServer(res.end(JSON.stringify(metrics.snapshot()))return}++ // Every API route has answered above; what is left under /api is unknown.++ if (url === '/api' || url.startsWith('/api/')) {++ notFound()++ return++ }if (!root) {-- res.writeHead(404)-- res.end('not found')++ notFound()return}// Pretty page routes: on the apex, / is the games portal, /play the// game and /editor the map editor. On the play host the game IS the// root page (with /play redirecting there so old links keep working).-- // The old .html URLs redirect so bookmarks keep working.++ // The old .html URLs and a trailing slash redirect to the canonical URL.if (url === '/play.html' || url === '/editor.html') {res.writeHead(301, {location: url === '/play.html' ? (playHost ? '/' : '/play') : '/editor',@@@ -619,14 +639,29 @@ export function createHttpServer(res.end()return}-- if (playHost && url === '/play') {++ if (playHost && (url === '/play' || url === '/play/')) {res.writeHead(301, { location: '/' })res.end()return}++ if (url === '/play/' || url === '/editor/') {++ res.writeHead(301, { location: url.slice(0, -1) })++ res.end()++ return++ }++ // The game's URLs from when it lived on OpenVibe.Live, which still links++ // and 301s to them: the game is /play now, and the pixel canvas is gone++ // (its nearest page is the portal).++ if (url === '/game' || url === '/canvas') {++ res.writeHead(301, { location: url === '/game' && !playHost ? '/play' : '/' })++ res.end()++ return++ }const pageAlias =-- playHost && url === '/'-- ? 'play.html'++ url === '/'++ ? playHost++ ? 'play.html'++ : 'index.html': url === '/play'? 'play.html': url === '/editor'@@@ -635,21 +670,21 @@ export function createHttpServer(// Static files with path traversal guard.const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')-- let filePath = join(root, safePath)++ const filePath = join(root, safePath)if (!filePath.startsWith(root)) {res.writeHead(403)res.end()return}if (!existsSync(filePath) || statSync(filePath).isDirectory()) {-- // Unknown paths fall back to the host's landing page (apex: portal-- // home; play host: the game itself).-- filePath = join(root, playHost ? 'play.html' : 'index.html')-- if (!existsSync(filePath)) {++ if (pageAlias) {res.writeHead(404)res.end('client build missing')return}++ // No route and no such file: a real 404, not the landing page.++ notFound()++ return}const type = MIME[extname(filePath)] ?? 'application/octet-stream'res.writeHead(200, {nnew file mode 100644@@@ -0,0 +1,113 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import { extname } from 'node:path'++++/**++ * The answer for a path no route matches: a real 404, never the landing page++ * with a 200 (a "soft 404" that search engines index as a duplicate of the++ * portal and monitors read as up).++ *++ * Page requests get a small self-contained HTML page that names the path and++ * links to the host's real entry points. API paths and clients asking for++ * JSON get `{"error":"not_found"}` (the shape /auth/* already answers). A++ * missing asset (any extension but .html) gets plain text.++ */++++export interface NotFoundLink {++ href: string++ label: string++}++++export type NotFoundKind = 'html' | 'json' | 'text'++++const ENTITIES: Record<string, string> = {++ '&': '&',++ '<': '<',++ '>': '>',++ '"': '"',++ "'": ''',++}++++export function escapeHtml(value: string): string {++ return value.replace(/[&<>"']/g, (c) => ENTITIES[c] ?? c)++}++++/** Which body a 404 for `path` gets. */++export function notFoundKind(req: IncomingMessage, path: string): NotFoundKind {++ if (path === '/api' || path.startsWith('/api/')) return 'json'++ const accept = (req.headers.accept ?? '').toLowerCase()++ if (accept.includes('application/json') && !accept.includes('text/html')) return 'json'++ const ext = extname(path).toLowerCase()++ if (ext !== '' && ext !== '.html' && ext !== '.htm') return 'text'++ return 'html'++}++++/** Longest request path echoed back on the page; the rest is cut. */++const MAX_SHOWN_PATH = 200++++export function notFoundPage(path: string, links: readonly NotFoundLink[]): string {++ const shown = path.length > MAX_SHOWN_PATH ? `${path.slice(0, MAX_SHOWN_PATH)}…` : path++ const items = links++ .map((l) => `<li><a href="${escapeHtml(l.href)}">${escapeHtml(l.label)}</a></li>`)++ .join('')++ return `<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<meta name="robots" content="noindex">++<title>Page not found · OpenVibe.Games</title>++<style>++:root { color-scheme: dark; }++* { box-sizing: border-box; margin: 0; }++body { min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;++ font-family: system-ui, -apple-system, sans-serif; background: #0a0f1c; color: #e6edf7; }++main { width: 100%; max-width: 32rem; padding: 32px 28px; border: 1px solid #1f2d47;++ border-radius: 14px; background: #101828; }++.code { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.12em; color: #60a5fa; }++h1 { margin: 6px 0 12px; font-size: 1.6rem; }++p { color: #96a7c2; line-height: 1.5; }++code { color: #e6edf7; word-break: break-all; }++ul { list-style: none; padding: 0; margin-top: 22px; display: flex; flex-wrap: wrap; gap: 10px; }++a { display: inline-block; padding: 8px 14px; border-radius: 8px; background: #1d4ed8;++ color: #fff; text-decoration: none; font-weight: 600; }++a:hover, a:focus-visible { background: #3b82f6; }++</style>++</head>++<body>++<main>++<p class="code">404</p>++<h1>Page not found</h1>++<p>There is no page at <code>${escapeHtml(shown)}</code>. It may have moved, or the link may be wrong.</p>++<ul>${items}</ul>++</main>++</body>++</html>++`++}++++/** Answers 404 for `path` with the body its client can use; HEAD gets the headers only. */++export function sendNotFound(++ req: IncomingMessage,++ res: ServerResponse,++ path: string,++ links: readonly NotFoundLink[],++): void {++ const kind = notFoundKind(req, path)++ const body =++ kind === 'html'++ ? notFoundPage(path, links)++ : kind === 'json'++ ? '{"error":"not_found"}'++ : 'not found\n'++ res.writeHead(404, {++ 'content-type':++ kind === 'html'++ ? 'text/html; charset=utf-8'++ : kind === 'json'++ ? 'application/json'++ : 'text/plain; charset=utf-8',++ 'content-length': Buffer.byteLength(body),++ 'cache-control': 'no-store',++ })++ res.end(req.method === 'HEAD' ? undefined : body)++}
@@ -83,7 +83,11 @@ STATIC_DIR=apps/client/dist PORT=8000 DB_PATH=data/world.db pnpm --filter @openv
@@ -83,7 +83,11 @@ STATIC_DIR=apps/client/dist PORT=8000 DB_PATH=data/world.db pnpm --filter @openv
```
```
The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game
The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game
-WebSocket on one port. `/api/ready` is 200 only while `world.db` answers a query and the simulation
+WebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` is
+the game and `/play` redirects there. `/game` and `/canvas` (the URLs from when the game lived on
+OpenVibe.Live) redirect to `/play` and `/`. Any other path that is neither a file of the build nor a route
+answers 404 (an HTML page with links to those pages; `{"error":"not_found"}` under `/api`; plain text
+for a missing asset), never the portal with a 200. `/api/ready` is 200 only while `world.db` answers a query and the simulation
has ticked within the last 5 s, otherwise 503 naming the failed check (`status`
has ticked within the last 5 s, otherwise 503 naming the failed check (`status`
`ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `DB_PATH`, `STATIC_DIR`,
`ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `DB_PATH`, `STATIC_DIR`,
`MAX_PLAYERS`, `LOG_LEVEL` (platform variables below).
`MAX_PLAYERS`, `LOG_LEVEL` (platform variables below).
@@ -1 +1 @@
@@ -1 +1 @@
-{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAsBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAyBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CAyhBR"}
\ No newline at end of file
\ No newline at end of file
+{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAuBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AA2BlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CAyjBR"}
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=httpServer.test.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"version":3,"file":"httpServer.test.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.test.ts"],"names":[],"mappings":""}
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+import type { IncomingMessage, ServerResponse } from 'node:http';
+/**
+ * The answer for a path no route matches: a real 404, never the landing page
+ * with a 200 (a "soft 404" that search engines index as a duplicate of the
+ * portal and monitors read as up).
+ *
+ * Page requests get a small self-contained HTML page that names the path and
+ * links to the host's real entry points. API paths and clients asking for
+ * JSON get `{"error":"not_found"}` (the shape /auth/* already answers). A
+ * missing asset (any extension but .html) gets plain text.
+ */
+export interface NotFoundLink {
+ href: string;
+ label: string;
+}
+export type NotFoundKind = 'html' | 'json' | 'text';
+export declare function escapeHtml(value: string): string;
+/** Which body a 404 for `path` gets. */
+export declare function notFoundKind(req: IncomingMessage, path: string): NotFoundKind;
+export declare function notFoundPage(path: string, links: readonly NotFoundLink[]): string;
+/** Answers 404 for `path` with the body its client can use; HEAD gets the headers only. */
+export declare function sendNotFound(req: IncomingMessage, res: ServerResponse, path: string, links: readonly NotFoundLink[]): void;
+//# sourceMappingURL=notFound.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"version":3,"file":"notFound.d.ts","sourceRoot":"","sources":["../../src/net/notFound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAGhE;;;;;;;;;GASG;AAEH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;AAUnD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,wCAAwC;AACxC,wBAAgB,YAAY,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAO7E;AAKD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAuCjF;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAC1B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,SAAS,YAAY,EAAE,GAC7B,IAAI,CAmBN"}
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,303 @@
@@ -0,0 +1,303 @@
+import { mkdirSync, mkdtempSync, 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 { join } from 'node:path'
+import { createConsoleLogger } from '@openvibe/shared'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { ServerMetrics } from '../observability/metrics.js'
+import { createReadiness } from '../observability/readiness.js'
+import { createHttpServer } from './httpServer.js'
+import { escapeHtml, notFoundPage } from './notFound.js'
+
+/**
+ * Routing of the real HTTP layer over a stand-in client build: every real
+ * route keeps its status, the play host serves the game at /, and a path no
+ * route matches is a 404 (never the landing page with a 200).
+ */
+
+const log = createConsoleLogger({ app: 'test' }, 'error')
+const APEX = 'openvibe.games'
+const PLAY = 'play.openvibe.games'
+const ASSET = '/assets/main-Cj5pXm1a.js'
+
+interface Reply {
+ status: number
+ headers: IncomingHttpHeaders
+ body: string
+}
+
+let dir: string
+let server: Server
+let bare: Server
+
+function listen(s: Server): Promise<number> {
+ return new Promise((resolve) =>
+ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),
+ )
+}
+
+let port = 0
+let barePort = 0
+
+/** A raw request (node:http, so the Host header and the path go out exactly as given). */
+function send(
+ path: string,
+ opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders; to?: number } = {},
+): Promise<Reply> {
+ return new Promise((resolve, reject) => {
+ const req = request(
+ {
+ host: '127.0.0.1',
+ port: opts.to ?? 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-http-'))
+ const dist = join(dir, 'dist')
+ mkdirSync(join(dist, 'assets', 'tex'), { recursive: true })
+ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')
+ writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')
+ writeFileSync(join(dist, 'editor.html'), '<!doctype html><title>editor page</title>')
+ writeFileSync(join(dist, 'chrome.js'), 'void 0')
+ writeFileSync(join(dist, ASSET.slice(1)), 'export {}')
+ writeFileSync(join(dist, 'assets', 'tex', 'wood_planks.jpg'), 'jpeg')
+ const mapPath = join(dir, 'data', 'map.json')
+ mkdirSync(join(dir, 'data', 'map-assets'), { recursive: true })
+ writeFileSync(join(dir, 'data', 'map-assets', 'tex-1700000000000.png'), 'png')
+
+ const metrics = new ServerMetrics()
+ metrics.tick = 42
+ metrics.lastTickAt = Date.now()
+ // /api/ready arrives through the platform hook, as main.ts wires it.
+ const readiness = createReadiness({ pingDb: () => true, metrics })
+ server = createHttpServer(
+ dist,
+ metrics,
+ log,
+ mapPath,
+ { key: 'editor-secret', networkAuthUrl: null },
+ undefined,
+ async () => [{ slot: 0, name: 'Ana', appearance: null }],
+ null,
+ { handle: (req, res) => readiness.handle(req, res) },
+ )
+ bare = createHttpServer(null, metrics, log)
+ port = await listen(server)
+ barePort = await listen(bare)
+})
+
+afterAll(() => {
+ server.close()
+ bare.close()
+ rmSync(dir, { recursive: true, force: true })
+})
+
+describe('real routes on the apex', () => {
+ it.each([
+ ['/', 'portal page'],
+ ['/play', 'game page'],
+ ['/play?sso=none', 'game page'],
+ ['/editor', 'editor page'],
+ ['/index.html', 'portal page'],
+ ])('%s answers 200 with its page', async (path, marker) => {
+ const r = await send(path)
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
+ expect(r.headers['cache-control']).toBe('no-cache')
+ expect(r.body).toContain(marker)
+ })
+
+ it.each([
+ ['/game', '/play'],
+ ['/canvas', '/'],
+ ])('%s (the old OpenVibe.Live URL) redirects to %s', async (path, location) => {
+ const r = await send(path)
+ expect(r.status).toBe(301)
+ expect(r.headers.location).toBe(location)
+ })
+
+ it('serves hashed build assets as immutable, and public files', async () => {
+ const asset = await send(ASSET)
+ expect(asset.status).toBe(200)
+ expect(asset.headers['content-type']).toBe('text/javascript')
+ expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable')
+ expect((await send('/chrome.js')).status).toBe(200)
+ expect((await send('/assets/tex/wood_planks.jpg')).status).toBe(200)
+ })
+
+ it('answers health, readiness, metrics and the API', async () => {
+ const health = await send('/healthz')
+ expect(health.status).toBe(200)
+ expect(JSON.parse(health.body)).toEqual({ ok: true, tick: 42 })
+ expect((await send('/api/ready')).status).toBe(200)
+ expect((await send('/metrics')).status).toBe(200)
+ const chars = await send('/api/characters?token=guest-token-1234')
+ expect(chars.status).toBe(200)
+ expect(JSON.parse(chars.body)).toEqual([{ slot: 0, name: 'Ana', appearance: null }])
+ const map = await send('/map.json')
+ expect(map.status).toBe(200)
+ expect(map.headers.etag).toBeTruthy()
+ const tex = await send('/map-assets/tex-1700000000000.png')
+ expect(tex.status).toBe(200)
+ expect(tex.headers['content-type']).toBe('image/png')
+ })
+
+ it('answers the SSO routes', async () => {
+ const me = await send('/auth/me')
+ expect(me.status).toBe(200)
+ expect(JSON.parse(me.body)).toEqual({ user: null })
+ const out = await send('/auth/logout')
+ expect(out.status).toBe(200)
+ expect(out.body).toContain('location.replace("/play")')
+ })
+
+ it.each([
+ ['/play.html', '/play'],
+ ['/editor.html', '/editor'],
+ ['/play/', '/play'],
+ ['/editor/', '/editor'],
+ ])('%s redirects to %s', async (path, location) => {
+ const r = await send(path)
+ expect(r.status).toBe(301)
+ expect(r.headers.location).toBe(location)
+ })
+
+ it('answers HEAD like GET, without a body', async () => {
+ const r = await send('/', { method: 'HEAD' })
+ expect(r.status).toBe(200)
+ expect(r.body).toBe('')
+ })
+})
+
+describe('unknown paths answer 404', () => {
+ it.each(['/nope', '/play/extra', '/editor/some-map', '/assets', '/assets/', '/old-page.html'])(
+ '%s is a 404 page, not the portal',
+ async (path) => {
+ const r = await send(path)
+ expect(r.status).toBe(404)
+ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
+ expect(r.headers['cache-control']).toBe('no-store')
+ expect(r.body).toContain('<title>Page not found · OpenVibe.Games</title>')
+ expect(r.body).not.toContain('portal page')
+ expect(r.body).toContain('<a href="/">OpenVibe.Games home</a>')
+ expect(r.body).toContain('<a href="/play">Play Scraplandia</a>')
+ expect(r.body).toContain('<a href="/editor">Map editor</a>')
+ expect(r.body).not.toMatch(/<script\b/)
+ },
+ )
+
+ it('echoes the requested path escaped', async () => {
+ const r = await send(`/x<script>alert("1")</script>&'y`)
+ expect(r.status).toBe(404)
+ expect(r.body).not.toContain('<script>alert')
+ expect(r.body).toContain(
+ '<code>/x<script>alert("1")</script>&'y</code>',
+ )
+ const encoded = await send('/%3Cimg%20src%3Dx%3E')
+ expect(encoded.body).toContain('<code>/%3Cimg%20src%3Dx%3E</code>')
+ })
+
+ it('gives HEAD and POST the same status', async () => {
+ const head = await send('/nope', { method: 'HEAD' })
+ expect(head.status).toBe(404)
+ expect(head.headers['content-type']).toBe('text/html; charset=utf-8')
+ expect(head.body).toBe('')
+ expect((await send('/nope', { method: 'POST' })).status).toBe(404)
+ })
+
+ it('answers a missing asset with a plain-text 404', async () => {
+ for (const path of ['/assets/main-Missing1.js', '/favicon.ico', '/assets/tex/nope.jpg']) {
+ const r = await send(path)
+ expect(r.status).toBe(404)
+ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')
+ expect(r.body).toBe('not found\n')
+ }
+ expect((await send('/map-assets/tex-1.png')).status).toBe(404)
+ })
+
+ it('answers unknown API paths and JSON clients with the JSON 404', async () => {
+ for (const path of ['/api/nope', '/api', '/api/map', '/auth/nope']) {
+ const r = await send(path)
+ expect(r.status).toBe(404)
+ expect(r.headers['content-type']).toBe('application/json')
+ expect(JSON.parse(r.body)).toEqual({ error: 'not_found' })
+ }
+ const json = await send('/nope', { headers: { accept: 'application/json' } })
+ expect(json.status).toBe(404)
+ expect(JSON.parse(json.body)).toEqual({ error: 'not_found' })
+ })
+})
+
+describe('play.openvibe.games', () => {
+ it('serves the game at /', async () => {
+ const r = await send('/', { host: PLAY })
+ expect(r.status).toBe(200)
+ expect(r.body).toContain('game page')
+ })
+
+ it.each(['/play', '/play/', '/play.html', '/game', '/canvas'])(
+ '%s redirects to /',
+ async (path) => {
+ const r = await send(path, { host: PLAY })
+ expect(r.status).toBe(301)
+ expect(r.headers.location).toBe('/')
+ },
+ )
+
+ it('serves the editor, assets, map and health like the apex', async () => {
+ expect((await send('/editor', { host: PLAY })).body).toContain('editor page')
+ expect((await send(ASSET, { host: PLAY })).status).toBe(200)
+ expect((await send('/map.json', { host: PLAY })).status).toBe(200)
+ expect((await send('/healthz', { host: PLAY })).status).toBe(200)
+ })
+
+ it('answers an unknown path with 404, not the game', async () => {
+ const r = await send('/nope', { host: PLAY })
+ expect(r.status).toBe(404)
+ expect(r.body).not.toContain('game page')
+ expect(r.body).toContain('<a href="/">Play Scraplandia</a>')
+ expect(r.body).toContain('<a href="https://openvibe.games/">OpenVibe.Games home</a>')
+ expect((await send('/assets/nope.js', { host: PLAY })).status).toBe(404)
+ })
+})
+
+describe('without a client build (API/WS only)', () => {
+ it('still answers health, and 404 for pages', async () => {
+ expect((await send('/healthz', { to: barePort })).status).toBe(200)
+ const r = await send('/', { to: barePort })
+ expect(r.status).toBe(404)
+ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
+ })
+})
+
+describe('404 page', () => {
+ it('escapes every link and cuts a very long path', () => {
+ expect(escapeHtml(`<a href="x">'&'</a>`)).toBe(
+ '<a href="x">'&'</a>',
+ )
+ const page = notFoundPage(`/${'a'.repeat(500)}`, [{ href: '/?a=1&b="2"', label: '<b>' }])
+ expect(page).toContain('<a href="/?a=1&b="2""><b></a>')
+ expect(page).toContain(`/${'a'.repeat(199)}…</code>`)
+ expect(page).not.toContain('a'.repeat(201))
+ })
+})
@@ -25,14 +25,17 @@ import {
@@ -25,14 +25,17 @@ import {
} from './sso.js'
} from './sso.js'
import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'
import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'
import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'
import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'
+import { sendNotFound, type NotFoundLink } from './notFound.js'
import type { MapFileV2 } from '@openvibe/content'
import type { MapFileV2 } from '@openvibe/content'
/**
/**
* Minimal HTTP layer: health/metrics endpoints and (in production) the
* Minimal HTTP layer: health/metrics endpoints and (in production) the
- * built client bundle. Game traffic itself is WebSocket-only.
+ * built client bundle. Game traffic itself is WebSocket-only. A path no
+ * route matches is a 404 (see notFound.ts), never the landing page.
*/
*/
const DEFAULT_NETWORK_URL = 'https://openvibe.network'
const DEFAULT_NETWORK_URL = 'https://openvibe.network'
+const DEFAULT_PORTAL_URL = 'https://openvibe.games'
const MIME: Record<string, string> = {
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.html': 'text/html; charset=utf-8',
@@ -138,12 +141,25 @@ export function createHttpServer(
@@ -138,12 +141,25 @@ export function createHttpServer(
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// browser answer without the Network round trip. See /auth/login.
// browser answer without the Network round trip. See /auth/login.
const knownSessions = new SessionCheckCache()
const knownSessions = new SessionCheckCache()
+ // The real entry points, offered on the 404 page of each host.
+ const portalHome = `${(oauth?.selfUrl ?? DEFAULT_PORTAL_URL).replace(/\/+$/, '')}/`
+ const apexLinks: NotFoundLink[] = [
+ { href: '/', label: 'OpenVibe.Games home' },
+ { href: '/play', label: 'Play Scraplandia' },
+ { href: '/editor', label: 'Map editor' },
+ ]
+ const playLinks: NotFoundLink[] = [
+ { href: '/', label: 'Play Scraplandia' },
+ { href: '/editor', label: 'Map editor' },
+ { href: portalHome, label: 'OpenVibe.Games home' },
+ ]
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
// (the apex root is the games portal). Everything else — /assets,
// (the apex root is the games portal). Everything else — /assets,
// /map.json, /api, /auth, websockets — behaves identically on both.
// /map.json, /api, /auth, websockets — behaves identically on both.
const playHost = (req.headers.host ?? '').toLowerCase().startsWith('play.')
const playHost = (req.headers.host ?? '').toLowerCase().startsWith('play.')
+ const notFound = () => sendNotFound(req, res, url, playHost ? playLinks : apexLinks)
if (platform?.handle?.(req, res)) return
if (platform?.handle?.(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
@@ -603,15 +619,19 @@ export function createHttpServer(
@@ -603,15 +619,19 @@ export function createHttpServer(
res.end(JSON.stringify(metrics.snapshot()))
res.end(JSON.stringify(metrics.snapshot()))
return
return
}
}
+ // Every API route has answered above; what is left under /api is unknown.
+ if (url === '/api' || url.startsWith('/api/')) {
+ notFound()
+ return
+ }
if (!root) {
if (!root) {
- res.writeHead(404)
- res.end('not found')
+ notFound()
return
return
}
}
// Pretty page routes: on the apex, / is the games portal, /play the
// Pretty page routes: on the apex, / is the games portal, /play the
// game and /editor the map editor. On the play host the game IS the
// game and /editor the map editor. On the play host the game IS the
// root page (with /play redirecting there so old links keep working).
// root page (with /play redirecting there so old links keep working).
- // The old .html URLs redirect so bookmarks keep working.
+ // The old .html URLs and a trailing slash redirect to the canonical URL.
if (url === '/play.html' || url === '/editor.html') {
if (url === '/play.html' || url === '/editor.html') {
res.writeHead(301, {
res.writeHead(301, {
location: url === '/play.html' ? (playHost ? '/' : '/play') : '/editor',
location: url === '/play.html' ? (playHost ? '/' : '/play') : '/editor',
@@ -619,14 +639,29 @@ export function createHttpServer(
@@ -619,14 +639,29 @@ export function createHttpServer(
res.end()
res.end()
return
return
}
}
- if (playHost && url === '/play') {
+ if (playHost && (url === '/play' || url === '/play/')) {
res.writeHead(301, { location: '/' })
res.writeHead(301, { location: '/' })
res.end()
res.end()
return
return
}
}
+ if (url === '/play/' || url === '/editor/') {
+ res.writeHead(301, { location: url.slice(0, -1) })
+ res.end()
+ return
+ }
+ // The game's URLs from when it lived on OpenVibe.Live, which still links
+ // and 301s to them: the game is /play now, and the pixel canvas is gone
+ // (its nearest page is the portal).
+ if (url === '/game' || url === '/canvas') {
+ res.writeHead(301, { location: url === '/game' && !playHost ? '/play' : '/' })
+ res.end()
+ return
+ }
const pageAlias =
const pageAlias =
- playHost && url === '/'
- ? 'play.html'
+ url === '/'
+ ? playHost
+ ? 'play.html'
+ : 'index.html'
: url === '/play'
: url === '/play'
? 'play.html'
? 'play.html'
: url === '/editor'
: url === '/editor'
@@ -635,21 +670,21 @@ export function createHttpServer(
@@ -635,21 +670,21 @@ export function createHttpServer(
// Static files with path traversal guard.
// Static files with path traversal guard.
const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')
const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')
- let filePath = join(root, safePath)
+ const filePath = join(root, safePath)
if (!filePath.startsWith(root)) {
if (!filePath.startsWith(root)) {
res.writeHead(403)
res.writeHead(403)
res.end()
res.end()
return
return
}
}
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
- // Unknown paths fall back to the host's landing page (apex: portal
- // home; play host: the game itself).
- filePath = join(root, playHost ? 'play.html' : 'index.html')
- if (!existsSync(filePath)) {
+ if (pageAlias) {
res.writeHead(404)
res.writeHead(404)
res.end('client build missing')
res.end('client build missing')
return
return
}
}
+ // No route and no such file: a real 404, not the landing page.
+ notFound()
+ return
}
}
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
res.writeHead(200, {
res.writeHead(200, {
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { extname } from 'node:path'
+
+/**
+ * The answer for a path no route matches: a real 404, never the landing page
+ * with a 200 (a "soft 404" that search engines index as a duplicate of the
+ * portal and monitors read as up).
+ *
+ * Page requests get a small self-contained HTML page that names the path and
+ * links to the host's real entry points. API paths and clients asking for
+ * JSON get `{"error":"not_found"}` (the shape /auth/* already answers). A
+ * missing asset (any extension but .html) gets plain text.
+ */
+
+export interface NotFoundLink {
+ href: string
+ label: string
+}
+
+export type NotFoundKind = 'html' | 'json' | 'text'
+
+const ENTITIES: Record<string, string> = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+}
+
+export function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (c) => ENTITIES[c] ?? c)
+}
+
+/** Which body a 404 for `path` gets. */
+export function notFoundKind(req: IncomingMessage, path: string): NotFoundKind {
+ if (path === '/api' || path.startsWith('/api/')) return 'json'
+ const accept = (req.headers.accept ?? '').toLowerCase()
+ if (accept.includes('application/json') && !accept.includes('text/html')) return 'json'
+ const ext = extname(path).toLowerCase()
+ if (ext !== '' && ext !== '.html' && ext !== '.htm') return 'text'
+ return 'html'
+}
+
+/** Longest request path echoed back on the page; the rest is cut. */
+const MAX_SHOWN_PATH = 200
+
+export function notFoundPage(path: string, links: readonly NotFoundLink[]): string {
+ const shown = path.length > MAX_SHOWN_PATH ? `${path.slice(0, MAX_SHOWN_PATH)}…` : path
+ const items = links
+ .map((l) => `<li><a href="${escapeHtml(l.href)}">${escapeHtml(l.label)}</a></li>`)
+ .join('')
+ return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex">
+<title>Page not found · OpenVibe.Games</title>
+<style>
+:root { color-scheme: dark; }
+* { box-sizing: border-box; margin: 0; }
+body { min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;
+ font-family: system-ui, -apple-system, sans-serif; background: #0a0f1c; color: #e6edf7; }
+main { width: 100%; max-width: 32rem; padding: 32px 28px; border: 1px solid #1f2d47;
+ border-radius: 14px; background: #101828; }
+.code { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.12em; color: #60a5fa; }
+h1 { margin: 6px 0 12px; font-size: 1.6rem; }
+p { color: #96a7c2; line-height: 1.5; }
+code { color: #e6edf7; word-break: break-all; }
+ul { list-style: none; padding: 0; margin-top: 22px; display: flex; flex-wrap: wrap; gap: 10px; }
+a { display: inline-block; padding: 8px 14px; border-radius: 8px; background: #1d4ed8;
+ color: #fff; text-decoration: none; font-weight: 600; }
+a:hover, a:focus-visible { background: #3b82f6; }
+</style>
+</head>
+<body>
+<main>
+<p class="code">404</p>
+<h1>Page not found</h1>
+<p>There is no page at <code>${escapeHtml(shown)}</code>. It may have moved, or the link may be wrong.</p>
+<ul>${items}</ul>
+</main>
+</body>
+</html>
+`
+}
+
+/** Answers 404 for `path` with the body its client can use; HEAD gets the headers only. */
+export function sendNotFound(
+ req: IncomingMessage,
+ res: ServerResponse,
+ path: string,
+ links: readonly NotFoundLink[],
+): void {
+ const kind = notFoundKind(req, path)
+ const body =
+ kind === 'html'
+ ? notFoundPage(path, links)
+ : kind === 'json'
+ ? '{"error":"not_found"}'
+ : 'not found\n'
+ res.writeHead(404, {
+ 'content-type':
+ kind === 'html'
+ ? 'text/html; charset=utf-8'
+ : kind === 'json'
+ ? 'application/json'
+ : 'text/plain; charset=utf-8',
+ 'content-length': Buffer.byteLength(body),
+ 'cache-control': 'no-store',
+ })
+ res.end(req.method === 'HEAD' ? undefined : body)
+}