openvibers/openvibe.games Commits

Pages: content-address the frame's /shared files (#307)

OP OpenVibers committed 6 days ago · fdc8c4c0a9 · 4 files, +49 −11

The build's HTML links /shared/navbar.js, theme-loader.js, footer.js and
kit.css without ?v=, so the server (correctly) answered the five-minute policy
instead of immutable: every repeat visit revalidated each one. The server now
rewrites those references with the pinned file's own hash as it sends the page
(index, play, editor, worlds and casual), which is the hash it already serves
for that file, so the URL and the bytes cannot disagree. Icons and the
manifest are left as they were.

Changes

ddiff --git a/apps/server/src/casual/page.ts b/apps/server/src/casual/page.ts
iindex b2f6899..793403e 100644
---- a/apps/server/src/casual/page.ts
++++ b/apps/server/src/casual/page.ts
@@@ -6,6 +6,7 @@
import { readFileSync, statSync } from 'node:fs'
import { CASUAL_GAMES } from '@openvibe/casual'
import { casualJsonLdTag } from '../net/discovery.js'
++import { versionSharedRefs } from '../net/sharedAssets.js'
const SITE = 'https://openvibe.games'
const MARK = '<!--casual:head-->'
@@@ -66,9 +67,11 @@ export function casualPageHtml(filePath: string, id: string): string {
if (!memo || memo.path !== filePath || memo.mtimeMs !== mtimeMs) {
memo = { path: filePath, mtimeMs, raw: readFileSync(filePath, 'utf8') }
}
-- return memo.raw
-- .replace(MARK, casualHead(id))
-- .replace('<body>', `<body data-game="${esc(id)}">`)
-- .replaceAll('<!--casual:name-->', esc(CASUAL_GAMES[id]?.name ?? ''))
-- .replace('<!--casual:about-->', casualAbout(id))
++ return versionSharedRefs(
++ memo.raw
++ .replace(MARK, casualHead(id))
++ .replace('<body>', `<body data-game="${esc(id)}">`)
++ .replaceAll('<!--casual:name-->', esc(CASUAL_GAMES[id]?.name ?? ''))
++ .replace('<!--casual:about-->', casualAbout(id)),
++ )
}
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex d2a6ca8..10618d3 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -33,6 +33,7 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore
import type { GamesActorLimits } from './actorLimits.js'
import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
++import { versionSharedRefs } from './sharedAssets.js'
import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'
import {
addAgentHead,
@@@ -58,9 +59,9 @@ export function portalHtml(filePath: string): string {
if (portalMemo && portalMemo.path === filePath && portalMemo.mtimeMs === mtimeMs)
return portalMemo.html
const raw = readFileSync(filePath, 'utf8')
-- const html = raw.includes('</head>')
-- ? raw.replace('</head>', `${homeJsonLdTags()}\n </head>`)
-- : raw
++ const html = versionSharedRefs(
++ raw.includes('</head>') ? raw.replace('</head>', `${homeJsonLdTags()}\n </head>`) : raw,
++ )
portalMemo = { path: filePath, mtimeMs, html }
return html
}
@@@ -1072,7 +1073,7 @@ export function createHttpServer(
}
}
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
-- res.writeHead(200, {
++ const headers = {
'content-type': type,
// HTML must always revalidate (a stale page pins an old protocol
// version and can't join). Vite content-hashes what it builds into
@@@ -1091,7 +1092,16 @@ export function createHttpServer(
...(url === '/surf/embed'
? { 'content-security-policy': "frame-ancestors https://openvibe.surf 'self'" }
: {}),
-- })
++ }
++ if (filePath.endsWith('.html')) {
++ // play.html and editor.html are build output too: their /shared/ references
++ // get the pinned file's hash so the browser can keep it for a year.
++ const body = versionSharedRefs(readFileSync(filePath, 'utf8'))
++ res.writeHead(200, { ...headers, 'content-length': String(Buffer.byteLength(body)) })
++ res.end(req.method === 'HEAD' ? undefined : body)
++ return
++ }
++ res.writeHead(200, headers)
createReadStream(filePath)
.on('error', (err) => {
log.warn('static file error', { path: filePath, error: String(err) })
ddiff --git a/apps/server/src/net/sharedAssets.test.ts b/apps/server/src/net/sharedAssets.test.ts
iindex 2a8f992..50a45a9 100644
---- a/apps/server/src/net/sharedAssets.test.ts
++++ b/apps/server/src/net/sharedAssets.test.ts
@@@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
--import { sharedAssetsHandler } from './sharedAssets.js'
++import { sharedAssetsHandler, versionSharedRefs } from './sharedAssets.js'
// D42 (roadmap WS-P task 4): the game server serves its own pinned OpenVibe Frame files at /shared, and the
// client pages load them from there, never from openvibe.network.
@@@ -78,6 +78,15 @@ describe('/shared (D42)', () => {
expect((await fetch(`${base}/shared/navbar.js`, { method: 'POST' })).status).toBe(404)
})
++ it('turns a page\'s /shared references into content-addressed URLs', () => {
++ const html = versionSharedRefs(
++ '<script src="/shared/navbar.js" defer></script><link href="/shared/kit.css?v=old"><img src="/shared/icons/games-192.png">',
++ )
++ expect(html).toContain(`src="/shared/navbar.js?v=${serve.hashOf('navbar.js')}"`)
++ expect(html).toContain(`href="/shared/kit.css?v=${serve.hashOf('kit.css')}"`)
++ expect(html).toContain('src="/shared/icons/games-192.png"')
++ })
++
it('the client pages load the frame from /shared, not openvibe.network', () => {
const client = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'client')
for (const page of ['index.html', 'play.html', 'editor.html']) {
ddiff --git a/apps/server/src/net/sharedAssets.ts b/apps/server/src/net/sharedAssets.ts
iindex 700ca3e..571a89d 100644
---- a/apps/server/src/net/sharedAssets.ts
++++ b/apps/server/src/net/sharedAssets.ts
@@@ -34,6 +34,22 @@ export function contentTypeOf(name: string): string {
return 'application/javascript; charset=utf-8'
}
++const SHARED_REF = /(["'])\/shared\/([A-Za-z0-9][A-Za-z0-9._-]*)(\?[^"']*)?\1/g
++
++/**
++ * Add `?v=<content hash>` to every `/shared/<file>` reference in a page's HTML, so the browser caches
++ * the pinned shared file for a year (the URL is content-addressed) instead of revalidating it every
++ * five minutes. The hash is the one this server already serves for the file, so the URL and the bytes
++ * cannot disagree. Files that are not browser files (icons, social cards, the manifest) are left alone.
++ */
++export function versionSharedRefs(html: string, req: NodeRequire = createRequire(import.meta.url)): string {
++ const files = req('openvibe-shared/files') as SharedFiles
++ const serve = req('openvibe-shared/serve') as SharedServe
++ return html.replace(SHARED_REF, (whole, quote: string, name: string) =>
++ files.isBrowserFile(name) ? `${quote}/shared/${name}?v=${serve.hashOf(name)}${quote}` : whole,
++ )
++}
++
export function sharedAssetsHandler(req: NodeRequire = createRequire(import.meta.url)) {
const files = req('openvibe-shared/files') as SharedFiles
const serve = req('openvibe-shared/serve') as SharedServe