openvibers/openvibe.games Commits
Pages: content-address the frame's /shared files (#307)
Changes
@@@ -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)),++ )}@@@ -33,6 +33,7 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStoreimport 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.htmlconst 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) })@@@ -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']) {@@@ -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 SharedFilesconst serve = req('openvibe-shared/serve') as SharedServe
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
import { readFileSync, statSync } from 'node:fs'
import { readFileSync, statSync } from 'node:fs'
import { CASUAL_GAMES } from '@openvibe/casual'
import { CASUAL_GAMES } from '@openvibe/casual'
import { casualJsonLdTag } from '../net/discovery.js'
import { casualJsonLdTag } from '../net/discovery.js'
+import { versionSharedRefs } from '../net/sharedAssets.js'
const SITE = 'https://openvibe.games'
const SITE = 'https://openvibe.games'
const MARK = '<!--casual:head-->'
const MARK = '<!--casual:head-->'
@@ -66,9 +67,11 @@ export function casualPageHtml(filePath: string, id: string): string {
@@ -66,9 +67,11 @@ export function casualPageHtml(filePath: string, id: string): string {
if (!memo || memo.path !== filePath || memo.mtimeMs !== mtimeMs) {
if (!memo || memo.path !== filePath || memo.mtimeMs !== mtimeMs) {
memo = { path: filePath, mtimeMs, raw: readFileSync(filePath, 'utf8') }
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)),
+ )
}
}
@@ -33,6 +33,7 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore
@@ -33,6 +33,7 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore
import type { GamesActorLimits } from './actorLimits.js'
import type { GamesActorLimits } from './actorLimits.js'
import { resolveClientAddress } from './clientAddress.js'
import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
+import { versionSharedRefs } from './sharedAssets.js'
import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'
import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'
import {
import {
addAgentHead,
addAgentHead,
@@ -58,9 +59,9 @@ export function portalHtml(filePath: string): string {
@@ -58,9 +59,9 @@ export function portalHtml(filePath: string): string {
if (portalMemo && portalMemo.path === filePath && portalMemo.mtimeMs === mtimeMs)
if (portalMemo && portalMemo.path === filePath && portalMemo.mtimeMs === mtimeMs)
return portalMemo.html
return portalMemo.html
const raw = readFileSync(filePath, 'utf8')
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 }
portalMemo = { path: filePath, mtimeMs, html }
return html
return html
}
}
@@ -1072,7 +1073,7 @@ export function createHttpServer(
@@ -1072,7 +1073,7 @@ export function createHttpServer(
}
}
}
}
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
- res.writeHead(200, {
+ const headers = {
'content-type': type,
'content-type': type,
// HTML must always revalidate (a stale page pins an old protocol
// HTML must always revalidate (a stale page pins an old protocol
// version and can't join). Vite content-hashes what it builds into
// version and can't join). Vite content-hashes what it builds into
@@ -1091,7 +1092,16 @@ export function createHttpServer(
@@ -1091,7 +1092,16 @@ export function createHttpServer(
...(url === '/surf/embed'
...(url === '/surf/embed'
? { 'content-security-policy': "frame-ancestors https://openvibe.surf 'self'" }
? { '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)
createReadStream(filePath)
.on('error', (err) => {
.on('error', (err) => {
log.warn('static file error', { path: filePath, error: String(err) })
log.warn('static file error', { path: filePath, error: String(err) })
@@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs'
@@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
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
// 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.
// client pages load them from there, never from openvibe.network.
@@ -78,6 +78,15 @@ describe('/shared (D42)', () => {
@@ -78,6 +78,15 @@ describe('/shared (D42)', () => {
expect((await fetch(`${base}/shared/navbar.js`, { method: 'POST' })).status).toBe(404)
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', () => {
it('the client pages load the frame from /shared, not openvibe.network', () => {
const client = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'client')
const client = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'client')
for (const page of ['index.html', 'play.html', 'editor.html']) {
for (const page of ['index.html', 'play.html', 'editor.html']) {
@@ -34,6 +34,22 @@ export function contentTypeOf(name: string): string {
@@ -34,6 +34,22 @@ export function contentTypeOf(name: string): string {
return 'application/javascript; charset=utf-8'
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)) {
export function sharedAssetsHandler(req: NodeRequire = createRequire(import.meta.url)) {
const files = req('openvibe-shared/files') as SharedFiles
const files = req('openvibe-shared/files') as SharedFiles
const serve = req('openvibe-shared/serve') as SharedServe
const serve = req('openvibe-shared/serve') as SharedServe