openvibers/openvibe.games Commits
Games: /updates — what shipped on openvibe.games (the shared update log) (#78)
Changes
@@@ -174,6 +174,13 @@ function publicPageDocs(): PublicPageDoc[] {text: pack.tagline,priority: 0.8,})),++ {++ title: 'What shipped',++ url: `${SITE}/updates`,++ note: 'every change deployed to OpenVibe.Games, newest first',++ text: "The update log of OpenVibe.Games: every change deployed to the site and its games, newest first, each a commit from the OpenVibers repositories linked to the change itself (the network's shared log; the whole network's is at https://openvibe.network/updates).",++ priority: 0.4,++ },]}@@@ -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 { updatesHandler } from './updatesPage.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@@@ -200,6 +201,8 @@ export function createHttpServer(]// The site's own /terms, /privacy and /dmca (openvibe-shared/legal), built once for the process.const serveLegal = legalHandler()++ // What shipped on the site (the shared update log), as on every OpenVibe site: the portal's host only.++ const serveUpdates = updatesHandler()// 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@@@ -243,6 +246,7 @@ export function createHttpServer(// 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.if (serveLegal(req, res)) return++ if (!playHost && serveUpdates(req, res)) returnconst requestedMapPlace = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')if (url === '/map.json' && requestedMapPlace && requestedMapPlace !== defaultPlaceId) {res.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' })nnew file mode 100644@@@ -0,0 +1,46 @@++import { describe, expect, it } from 'vitest'++import type { IncomingMessage, ServerResponse } from 'node:http'++import { updatesHandler } from './updatesPage.js'++++function call(handler: ReturnType<typeof updatesHandler>, url: string, method = 'GET') {++ let status = 0++ let headers: Record<string, string> = {}++ let body = ''++ const res = {++ writeHead: (code: number, h?: Record<string, string>) => {++ status = code++ headers = h ?? {}++ },++ end: (b?: string) => {++ body = b ?? ''++ },++ } as unknown as ServerResponse++ const handled = handler({ url, method } as IncomingMessage, res)++ return { handled, status, headers, body }++}++++describe('GET /updates: the shared update log', () => {++ const handler = updatesHandler()++++ it('answers the log in the Frame, with one heading and its canonical address', () => {++ const r = call(handler, '/updates')++ expect(r.handled).toBe(true)++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toMatch(/^text\/html/)++ expect(r.headers['cache-control']).toBe('public, max-age=300')++ expect(r.body).toContain('<h1>What shipped on OpenVibe.Games</h1>')++ expect(r.body).toContain('data-ov-shipped="log" data-service="games"')++ expect((r.body.match(/<h1[\s>]/g) ?? []).length).toBe(1)++ expect(r.body).toContain('<link rel="canonical" href="https://openvibe.games/updates">')++ expect(r.body).toMatch(/<script src="\/shared\/shipped\.js\?v=[0-9a-f]+" defer><\/script>/)++ })++++ it('leaves every other path alone and refuses other methods', () => {++ expect(call(handler, '/updates/x').handled).toBe(false)++ expect(call(handler, '/').handled).toBe(false)++ const post = call(handler, '/updates', 'POST')++ expect([post.handled, post.status]).toEqual([true, 405])++ const head = call(handler, '/updates?x=1', 'HEAD')++ expect([head.status, head.body]).toEqual([200, ''])++ })++})nnew file mode 100644@@@ -0,0 +1,81 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import { createRequire } from 'node:module'++++/**++ * GET /updates: what shipped on openvibe.games, the network's shared update log (openvibe-shared/frame updatesBody and++ * shipped.js) inside the OpenVibe Frame, as every OpenVibe site serves it. openvibe.help links each site's /updates.++ *++ * The page is rendered once per process by openvibe-shared/shell (this server is plain node:http, like legalPages.ts);++ * the log itself is filled in the browser from openvibe.network's changelog.++ */++interface Shell {++ page(o: Record<string, unknown>): string++}++interface Frame {++ updatesBody(o: { service: string; siteName: string }): string++}++interface Serve {++ url(p: string): string++}++++const SITE_NAME = 'OpenVibe.Games'++const SITE_URL = 'https://openvibe.games'++const NETWORK_URL = 'https://openvibe.network'++++export function updatesHandler(req: NodeRequire = createRequire(import.meta.url)) {++ const shell = req('openvibe-shared/shell') as Shell++ const frame = req('openvibe-shared/frame') as Frame++ const serve = req('openvibe-shared/serve') as Serve++ let html: string | null = null++ const render = (): string => {++ if (html) return html++ const links = [++ { label: 'Play', href: '/play' },++ { label: 'Leaderboard', href: '/leaderboard.html' },++ ]++ html = shell.page({++ name: SITE_NAME,++ service: 'games',++ lang: 'en',++ title: `What shipped on ${SITE_NAME}`,++ siteName: SITE_NAME,++ description: `Every change deployed to ${SITE_NAME}, newest first.`,++ canonical: `${SITE_URL}/updates`,++ robots: 'index, follow',++ navbar: {++ service: 'games',++ apiBase: NETWORK_URL,++ links,++ history: { type: 'page', title: SITE_NAME },++ },++ footer: {++ service: 'games',++ variant: 'full',++ mount: '#ov-footer',++ brandName: SITE_NAME,++ updates: '/updates',++ },++ home: '/',++ navLinks: links,++ body: `<div id="navbar-mount"></div>\n<main id="main" class="page">\n${frame.updatesBody({ service: 'games', siteName: SITE_NAME })}\n</main>\n<script src="${serve.url('shipped.js')}" defer></script>`,++ })++ return html++ }++ return (rq: IncomingMessage, res: ServerResponse): boolean => {++ const url = (rq.url ?? '/').split('?')[0] ?? '/'++ if (url !== '/updates') return false++ if (rq.method !== 'GET' && rq.method !== 'HEAD') {++ res.writeHead(405, { allow: 'GET, HEAD' })++ res.end()++ return true++ }++ const body = render()++ res.writeHead(200, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'public, max-age=300',++ 'content-length': String(Buffer.byteLength(body)),++ })++ res.end(rq.method === 'HEAD' ? undefined : body)++ return true++ }++}nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/net/updatesPage.test.ts'++// covers: 'apps/server/src/net'++// Generated by node test/run.js --index; do not edit.
@@ -174,6 +174,13 @@ function publicPageDocs(): PublicPageDoc[] {
@@ -174,6 +174,13 @@ function publicPageDocs(): PublicPageDoc[] {
text: pack.tagline,
text: pack.tagline,
priority: 0.8,
priority: 0.8,
})),
})),
+ {
+ title: 'What shipped',
+ url: `${SITE}/updates`,
+ note: 'every change deployed to OpenVibe.Games, newest first',
+ text: "The update log of OpenVibe.Games: every change deployed to the site and its games, newest first, each a commit from the OpenVibers repositories linked to the change itself (the network's shared log; the whole network's is at https://openvibe.network/updates).",
+ priority: 0.4,
+ },
]
]
}
}
@@ -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 { updatesHandler } from './updatesPage.js'
import { indexnowHandler, type SharedIndexNow } from './indexnow.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
@@ -200,6 +201,8 @@ export function createHttpServer(
@@ -200,6 +201,8 @@ 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()
+ // What shipped on the site (the shared update log), as on every OpenVibe site: the portal's host only.
+ const serveUpdates = updatesHandler()
// GET /<key>.txt — the IndexNow key file, before the static files so it is never shadowed by a
// 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.
// build artifact of the same name. Absent when no key is configured.
const serveIndexNow = indexnow ? indexnowHandler(indexnow) : null
const serveIndexNow = indexnow ? indexnowHandler(indexnow) : null
@@ -243,6 +246,7 @@ export function createHttpServer(
@@ -243,6 +246,7 @@ export function createHttpServer(
// 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.
if (serveLegal(req, res)) return
if (serveLegal(req, res)) return
+ if (!playHost && serveUpdates(req, res)) return
const requestedMapPlace = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')
const requestedMapPlace = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')
if (url === '/map.json' && requestedMapPlace && requestedMapPlace !== defaultPlaceId) {
if (url === '/map.json' && requestedMapPlace && requestedMapPlace !== defaultPlaceId) {
res.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { updatesHandler } from './updatesPage.js'
+
+function call(handler: ReturnType<typeof updatesHandler>, url: string, method = 'GET') {
+ let status = 0
+ let headers: Record<string, string> = {}
+ let body = ''
+ const res = {
+ writeHead: (code: number, h?: Record<string, string>) => {
+ status = code
+ headers = h ?? {}
+ },
+ end: (b?: string) => {
+ body = b ?? ''
+ },
+ } as unknown as ServerResponse
+ const handled = handler({ url, method } as IncomingMessage, res)
+ return { handled, status, headers, body }
+}
+
+describe('GET /updates: the shared update log', () => {
+ const handler = updatesHandler()
+
+ it('answers the log in the Frame, with one heading and its canonical address', () => {
+ const r = call(handler, '/updates')
+ expect(r.handled).toBe(true)
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toMatch(/^text\/html/)
+ expect(r.headers['cache-control']).toBe('public, max-age=300')
+ expect(r.body).toContain('<h1>What shipped on OpenVibe.Games</h1>')
+ expect(r.body).toContain('data-ov-shipped="log" data-service="games"')
+ expect((r.body.match(/<h1[\s>]/g) ?? []).length).toBe(1)
+ expect(r.body).toContain('<link rel="canonical" href="https://openvibe.games/updates">')
+ expect(r.body).toMatch(/<script src="\/shared\/shipped\.js\?v=[0-9a-f]+" defer><\/script>/)
+ })
+
+ it('leaves every other path alone and refuses other methods', () => {
+ expect(call(handler, '/updates/x').handled).toBe(false)
+ expect(call(handler, '/').handled).toBe(false)
+ const post = call(handler, '/updates', 'POST')
+ expect([post.handled, post.status]).toEqual([true, 405])
+ const head = call(handler, '/updates?x=1', 'HEAD')
+ expect([head.status, head.body]).toEqual([200, ''])
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createRequire } from 'node:module'
+
+/**
+ * GET /updates: what shipped on openvibe.games, the network's shared update log (openvibe-shared/frame updatesBody and
+ * shipped.js) inside the OpenVibe Frame, as every OpenVibe site serves it. openvibe.help links each site's /updates.
+ *
+ * The page is rendered once per process by openvibe-shared/shell (this server is plain node:http, like legalPages.ts);
+ * the log itself is filled in the browser from openvibe.network's changelog.
+ */
+interface Shell {
+ page(o: Record<string, unknown>): string
+}
+interface Frame {
+ updatesBody(o: { service: string; siteName: string }): string
+}
+interface Serve {
+ url(p: string): string
+}
+
+const SITE_NAME = 'OpenVibe.Games'
+const SITE_URL = 'https://openvibe.games'
+const NETWORK_URL = 'https://openvibe.network'
+
+export function updatesHandler(req: NodeRequire = createRequire(import.meta.url)) {
+ const shell = req('openvibe-shared/shell') as Shell
+ const frame = req('openvibe-shared/frame') as Frame
+ const serve = req('openvibe-shared/serve') as Serve
+ let html: string | null = null
+ const render = (): string => {
+ if (html) return html
+ const links = [
+ { label: 'Play', href: '/play' },
+ { label: 'Leaderboard', href: '/leaderboard.html' },
+ ]
+ html = shell.page({
+ name: SITE_NAME,
+ service: 'games',
+ lang: 'en',
+ title: `What shipped on ${SITE_NAME}`,
+ siteName: SITE_NAME,
+ description: `Every change deployed to ${SITE_NAME}, newest first.`,
+ canonical: `${SITE_URL}/updates`,
+ robots: 'index, follow',
+ navbar: {
+ service: 'games',
+ apiBase: NETWORK_URL,
+ links,
+ history: { type: 'page', title: SITE_NAME },
+ },
+ footer: {
+ service: 'games',
+ variant: 'full',
+ mount: '#ov-footer',
+ brandName: SITE_NAME,
+ updates: '/updates',
+ },
+ home: '/',
+ navLinks: links,
+ body: `<div id="navbar-mount"></div>\n<main id="main" class="page">\n${frame.updatesBody({ service: 'games', siteName: SITE_NAME })}\n</main>\n<script src="${serve.url('shipped.js')}" defer></script>`,
+ })
+ return html
+ }
+ return (rq: IncomingMessage, res: ServerResponse): boolean => {
+ const url = (rq.url ?? '/').split('?')[0] ?? '/'
+ if (url !== '/updates') return false
+ if (rq.method !== 'GET' && rq.method !== 'HEAD') {
+ res.writeHead(405, { allow: 'GET, HEAD' })
+ res.end()
+ return true
+ }
+ const body = render()
+ res.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'public, max-age=300',
+ 'content-length': String(Buffer.byteLength(body)),
+ })
+ res.end(rq.method === 'HEAD' ? undefined : body)
+ return true
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/net/updatesPage.test.ts'
+// covers: 'apps/server/src/net'
+// Generated by node test/run.js --index; do not edit.