openvibers/openvibe.games Commits

Publish Games' New worlds card at /.well-known/openvibe-menu.json (#303)

OP OpenVibers committed 6 days ago · a0467ad3d2 · 4 files, +277 −0

* Publish Games' New worlds card at /.well-known/openvibe-menu.json

* Publish the New worlds card as a frame.menu-cards array

Changes

ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 8a0f8b4..25fbfb4 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -98,6 +98,7 @@ import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldHub.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
++import { createMenuCardHandler } from './net/menuCard.js'
import { createAssetRoutes } from './worlds/assetRoutes.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createWorldSharing } from './worlds/sharing.js'
@@@ -1063,6 +1064,8 @@ async function main(): Promise<void> {
...(worldSearch ? { search: worldSearch } : {}),
...(worldSharing ? { sharing: worldSharing } : {}),
})
++ // The menu's network card (frame.menu-card@1): /.well-known/openvibe-menu.json, the newest published worlds.
++ const serveMenuCard = createMenuCardHandler({ store: creatorWorlds })
// The uniform share API for worlds: /api/v1/world/<id>/share and the shared-with-me listing.
const serveWorldShare = worldSharing
? createWorldShareRoutes({
@@@ -1268,6 +1271,7 @@ async function main(): Promise<void> {
)
return true
}
++ if (serveMenuCard(req, res)) return true
if (serveCasual(req, res)) return true
if (serveAiMatches(req, res)) return true
if (serveWorldShare?.(req, res)) return true
ddiff --git a/apps/server/src/net/menuCard.test.ts b/apps/server/src/net/menuCard.test.ts
nnew file mode 100644
iindex 0000000..ff99076
---- /dev/null
++++ b/apps/server/src/net/menuCard.test.ts
@@@ -0,0 +1,157 @@
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { describe, expect, it } from 'vitest'
++import type { CreatorWorld } from '@openvibe/persistence'
++import { CARD_TTL_S, cardFrom, createMenuCardHandler, plain } from './menuCard.js'
++
++const NOW = Date.parse('2026-11-07T08:00:00Z')
++
++const world = (id: string, over: Partial<CreatorWorld> = {}): CreatorWorld => ({
++ id,
++ ownerSubject: `usr_${id}`,
++ ownerName: 'Builder',
++ title: `World ${id}`,
++ summary: 'A floating island',
++ status: 'published',
++ revision: 'r1',
++ plays: 3,
++ createdAt: NOW,
++ updatedAt: NOW,
++ publishedAt: NOW,
++ remixedFrom: null,
++ remixes: 0,
++ view: '3d',
++ ...over,
++})
++
++/** A ServerResponse that records the head and body and resolves when the handler ends it. */
++function fakeRes() {
++ const headers: Record<string, string | number | string[] | undefined> = {}
++ let status = 0
++ let body = ''
++ let done!: () => void
++ const ended = new Promise<void>((resolve) => {
++ done = resolve
++ })
++ const res = {
++ writeHead(code: number, h: Record<string, string | number | string[]> = {}) {
++ status = code
++ Object.assign(headers, h)
++ return this
++ },
++ end(chunk?: string) {
++ body = chunk ?? ''
++ done()
++ return this
++ },
++ } as unknown as ServerResponse
++ return { res, ended, headers, status: () => status, body: () => body }
++}
++
++const req = (url: string, method = 'GET') => ({ url, method }) as IncomingMessage
++
++const storeReturning = (rows: CreatorWorld[]) => {
++ let calls = 0
++ return {
++ calls: () => calls,
++ listPublished: async (limit: number, offset: number) => {
++ calls++
++ expect(limit).toBe(6)
++ expect(offset).toBe(0)
++ return rows
++ },
++ }
++}
++
++describe('the menu network card (frame.menu-card@1)', () => {
++ it('is the newest published worlds, newest first, linking each to its page', () => {
++ const card = cardFrom([world('a'), world('b', { title: 'Moon <b>base</b>' })], NOW)
++ expect(card).toMatchObject({
++ id: 'new-worlds',
++ site: 'games',
++ title: 'New worlds',
++ kind: 'rows',
++ updated_at: '2026-11-07T08:00:00.000Z',
++ ttl_s: CARD_TTL_S,
++ })
++ expect(card.more).toEqual({ label: 'All worlds', url: 'https://openvibe.games/worlds' })
++ expect(card.items).toEqual([
++ { title: 'World a', meta: 'by Builder', url: 'https://openvibe.games/worlds/a' },
++ { title: 'Moon base', meta: 'by Builder', url: 'https://openvibe.games/worlds/b' },
++ ])
++ expect(card.title.length).toBeLessThanOrEqual(60)
++ for (const item of card.items) {
++ expect(item.title.length).toBeLessThanOrEqual(80)
++ expect(item.meta.length).toBeLessThanOrEqual(120)
++ expect(item.url.startsWith('https://openvibe.games/')).toBe(true)
++ }
++ })
++
++ it('sends at most six, and never a draft or a hidden world', () => {
++ const many = Array.from({ length: 8 }, (_, i) => world(`w${i}`))
++ expect(cardFrom(many, NOW).items).toHaveLength(6)
++ expect(cardFrom([world('draft', { status: 'draft' })], NOW).items).toEqual([])
++ expect(cardFrom([world('hidden', { status: 'hidden' })], NOW).items).toEqual([])
++ expect(cardFrom([], NOW).items).toEqual([])
++ })
++
++ it('strips markup and control characters from the words', () => {
++ expect(plain('<i>Moon</i>\n\t base ', 80)).toBe('Moon base')
++ expect(plain('x'.repeat(200), 80)).toHaveLength(80)
++ expect(
++ cardFrom([world('a', { title: '<script>alert(1)</script>Moon' })], NOW).items[0]!.title,
++ ).toBe('alert(1) Moon')
++ })
++
++ it('answers GET /.well-known/openvibe-menu.json with CORS * and a cache, and nothing else', async () => {
++ const store = storeReturning([world('a')])
++ const handler = createMenuCardHandler({ store, now: () => NOW })
++ expect(handler(req('/other'), fakeRes().res)).toBe(false)
++
++ const first = fakeRes()
++ expect(handler(req('/.well-known/openvibe-menu.json'), first.res)).toBe(true)
++ await first.ended
++ expect(first.status()).toBe(200)
++ expect(first.headers['access-control-allow-origin']).toBe('*')
++ expect(first.headers['content-type']).toContain('application/json')
++ expect(first.headers['cache-control']).toBe('public, max-age=60')
++ const parsed = JSON.parse(first.body())
++ expect(Array.isArray(parsed) && parsed.length).toBe(1)
++ expect(parsed[0].site).toBe('games')
++
++ const second = fakeRes()
++ expect(handler(req('/.well-known/openvibe-menu.json'), second.res)).toBe(true)
++ await second.ended
++ expect(store.calls(), 'the second request is served from the cache').toBe(1)
++ })
++
++ it('answers 503 when the store fails and no good card is cached, and keeps the last good one after', async () => {
++ let fail = false
++ let calls = 0
++ const store = {
++ listPublished: async () => {
++ calls++
++ if (fail) throw new Error('db down')
++ return [world('a')]
++ },
++ }
++ const handler = createMenuCardHandler({ store, now: () => NOW })
++ const bad = fakeRes()
++ fail = true
++ handler(req('/.well-known/openvibe-menu.json'), bad.res)
++ await bad.ended
++ expect(bad.status()).toBe(503)
++
++ fail = false
++ const good = fakeRes()
++ handler(req('/.well-known/openvibe-menu.json'), good.res)
++ await good.ended
++ expect(good.status()).toBe(200)
++ fail = true
++ const kept = fakeRes()
++ handler(req('/.well-known/openvibe-menu.json'), kept.res)
++ await kept.ended
++ expect(kept.status()).toBe(200)
++ expect(JSON.parse(kept.body())[0].items[0].title).toBe('World a')
++ expect(calls).toBe(2)
++ })
++})
ddiff --git a/apps/server/src/net/menuCard.ts b/apps/server/src/net/menuCard.ts
nnew file mode 100644
iindex 0000000..d946081
---- /dev/null
++++ b/apps/server/src/net/menuCard.ts
@@@ -0,0 +1,113 @@
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'
++
++/**
++ * The menu's network card for OpenVibe.Games (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
++ *
++ * Games publishes "New worlds": the newest published creator worlds, each linking to its own page. The file is
++ * public and CORS *, cached for a minute (Network refetches every five minutes). Only fields a published world
++ * already shows on /worlds are copied — never the owner's subject, a draft, a hidden world or a map.
++ */
++export const SITE = 'games'
++export const CARD_ID = 'new-worlds'
++export const CARD_TTL_S = 300
++const CACHE_MS = 60_000
++const BASE = 'https://openvibe.games'
++
++/** Plain words for a card: markup and control characters out, one line, within the schema's limit. */
++export function plain(value: unknown, max: number): string {
++ const visible = Array.from(String(value ?? ''), (ch) => {
++ const code = ch.charCodeAt(0)
++ return code < 32 || code === 127 ? ' ' : ch
++ }).join('')
++ return visible
++ .replace(/<[^>]*>/g, ' ')
++ .replace(/\s+/g, ' ')
++ .trim()
++ .slice(0, max)
++}
++
++/** The card itself, newest published world first (the store's order). */
++export function cardFrom(worlds: CreatorWorld[], at = Date.now()) {
++ const items = (Array.isArray(worlds) ? worlds : [])
++ .filter((w) => w && w.id && w.status === 'published')
++ .slice(0, 6)
++ .map((w) => ({
++ title: plain(w.title, 80),
++ meta: `by ${plain(w.ownerName, 110)}`,
++ url: `${BASE}/worlds/${encodeURIComponent(w.id)}`,
++ }))
++ return {
++ id: CARD_ID,
++ site: SITE,
++ title: 'New worlds',
++ kind: 'rows',
++ items,
++ more: { label: 'All worlds', url: `${BASE}/worlds` },
++ updated_at: new Date(at).toISOString(),
++ ttl_s: CARD_TTL_S,
++ }
++}
++
++export interface MenuCardOptions {
++ store: Pick<CreatorWorldStore, 'listPublished'>
++ now?: () => number
++}
++
++/**
++ * The route. Returns true when it answered the request (the platform handle stops there), false for any other
++ * path. A database failure keeps the last good card; before one exists it answers 503 and Network keeps the
++ * site's previous card for its 30-minute window.
++ */
++export function createMenuCardHandler({ store, now = Date.now }: MenuCardOptions) {
++ let cached: ReturnType<typeof cardFrom> | null = null
++ let cachedAt = 0
++ let pending: Promise<ReturnType<typeof cardFrom>> | null = null
++
++ async function card(): Promise<ReturnType<typeof cardFrom>> {
++ const at = now()
++ if (cached && at - cachedAt < CACHE_MS) return cached
++ if (pending) return pending
++ pending = (async () => {
++ try {
++ cached = cardFrom(await store.listPublished(6, 0), at)
++ cachedAt = at
++ } catch (err) {
++ if (!cached) throw err
++ }
++ return cached as ReturnType<typeof cardFrom>
++ })().finally(() => {
++ pending = null
++ })
++ return pending
++ }
++
++ return (req: IncomingMessage, res: ServerResponse): boolean => {
++ const path = (req.url ?? '/').split('?')[0] ?? '/'
++ if (path !== '/.well-known/openvibe-menu.json') return false
++ if (req.method !== 'GET' && req.method !== 'HEAD') {
++ res.writeHead(405, { 'content-type': 'application/json', allow: 'GET, HEAD' })
++ res.end('{"error":"method_not_allowed"}')
++ return true
++ }
++ void card()
++ .then((body) => {
++ res.writeHead(200, {
++ 'content-type': 'application/json; charset=utf-8',
++ 'cache-control': 'public, max-age=60',
++ 'access-control-allow-origin': '*',
++ 'cross-origin-resource-policy': 'cross-origin',
++ })
++ res.end(req.method === 'HEAD' ? undefined : JSON.stringify([body]))
++ })
++ .catch(() => {
++ res.writeHead(503, {
++ 'content-type': 'application/json',
++ 'cache-control': 'no-store',
++ 'access-control-allow-origin': '*',
++ })
++ res.end('{"error":"menu_unavailable"}')
++ })
++ return true
++ }
++}
ddiff --git a/test/apps__server__src__net__menuCard.test.js b/test/apps__server__src__net__menuCard.test.js
nnew file mode 100644
iindex 0000000..438b734
---- /dev/null
++++ b/test/apps__server__src__net__menuCard.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/net/menuCard.test.ts'
++// covers: 'apps/server/src/net'
++// Generated by node test/run.js --index; do not edit.