openvibers/openvibe.live Commits

The shell's cache and the early hints: Live goes first (#732)

OP OpenVibers committed 4 days ago · 2ef392c023 · 4 files, +138 −0

* The shell's cache and the early hints: Live goes first

The pilot for the shared worker (openvibe-shared 4.164.0): Live opts in to the shared worker (sw: true in the navbar
options), so the shell's files come from the worker's cache on a later visit and a page always comes from the network first.

The server's side (server/web/sw.js): the 103 Early Hints for the shell - the first paint CSS, the navbar, the theme loader and
the app script start downloading before the HTML arrives - and the manifest the worker warms by content hash, so the worker
does not download a second copy of a file the page already asks for.

The worker itself arrives with the openvibe-shared 4.164.0 pin: until that pin lands, the hints work (the file does the same
two things by hand and the shared helper takes over with the pin) and the worker's opt-in waits for the navbar that reads it.

* Early hints as the Link header only: nginx 1.28 takes an upstream 103 as the final answer

* Merge remote-tracking branch 'origin/main' into feat/speed-shell-live

* Merge remote-tracking branch 'origin/main' into feat/speed-shell-live

Changes

ddiff --git a/public/js/ov-navbar-live.js b/public/js/ov-navbar-live.js
iindex 826301f9..52dcc1bc 100644
---- a/public/js/ov-navbar-live.js
++++ b/public/js/ov-navbar-live.js
@@@ -88,6 +88,9 @@
// injected rules broke the hash-based report-only CSP on every page view (11-03: a /api/csp-report POST
// each load; Chrome ignores 'inline-speculation-rules' beside hashes).
speculate: false,
++ // The shared worker (openvibe-sw.js, Shared 4.164.0): the shell's cache. The same file the bell registers for
++ // push, so there is one registration at scope "/" and nothing is unregistered by accident.
++ sw: true,
onLogout: () => call('logout'),
};
}
ddiff --git a/server/index.js b/server/index.js
iindex 1e6d16ff..9ce45884 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -426,6 +426,9 @@ app.use((req, res, next) => {
}
next();
});
++// The shell's early hints (server/web/shell.js): the navbar, the theme loader, the first paint CSS and the app script
++// start downloading in a 103, before the HTML arrives (Shared 4.164.0's worker, early hints and the manifest).
++app.use(require('./web/sw').hints());
/**
* Origin check. Exact allowlist first, then a strict suffix rule for the openvibe.tools
* satellites. Parsed with URL() and matched on hostname + https so a lookalike such as
@@@ -621,6 +624,12 @@ console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser fi
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(sharedServePath, 'openvibe-sw.js'));
});
++ // The shell the shared worker keeps in its cache (server/web/shell.js): the shared files every page loads, by content
++ // hash, so the worker warms the shell without downloading a second copy of what the page already asks for.
++ app.get('/ov-sw-manifest.json', (req, res) => {
++ res.setHeader('Cache-Control', 'public, max-age=300');
++ res.json(require('./web/sw').manifest());
++ });
// The share cards (og:image on every page), before the browser-file allowlist below.
require('./web/share-cards').register(app);
// Serve only the files the package lists for sites (scripts, stylesheets, fonts, images: server/web/shared-files.js),
ddiff --git a/server/web/sw.js b/server/web/sw.js
nnew file mode 100644
iindex 00000000..f8bfd37a
---- /dev/null
++++ b/server/web/sw.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * The shell, on the server's side: the early hints (openvibe-shared/sw, Shared 4.164.0) and the manifest the shared worker
++ * reads.
++ *
++ * The hints: the page's Link header names the navbar, the theme loader, the first paint CSS and the site's own app
++ * script as preloads, and Cloudflare's Early Hints turn that header into a 103 at the edge. Live never sends the 103
++ * itself: nginx 1.28 (the host's) takes an upstream 103 as the final response and sends the real page as its body
++ * (tried against nginx:1.28-alpine, 11-09). A 103 from here waits for nginx 1.29+ with `early_hints`.
++ *
++ * The manifest: the files the shared worker (openvibe-sw.js) keeps in its cache, by content hash, so the worker warms the
++ * shell without downloading a second copy of a file the page already asks for.
++ *
++ * The shared helper lands with the next openvibe-shared pin; until then this file does the same things by hand, and the
++ * pin makes the shared one take over (the same answer, one place).
++ */
++const assets = require('./assets');
++
++let shared = null;
++try { shared = require('openvibe-shared/sw'); } catch { /* the pin that carries the shared helper has not landed yet */ }
++
++/**
++ * The files every Live page loads, by content hash: the shell's scripts and the first paint CSS. The hash means the browser
++ * can keep them for a year, and means a release that changes them hands out new URLs.
++ */
++function shellLinks() {
++ return [
++ { href: assets.versioned('/css/style.core.css'), as: 'style' },
++ { href: assets.versioned('/shared/navbar.js'), as: 'script' },
++ { href: assets.versioned('/shared/theme-loader.js'), as: 'script' },
++ { href: assets.versioned('/js/app.js'), as: 'script' },
++ ].filter((l) => l && l.href);
++}
++
++/** The hints for a response, as its Link header (never a 103: see above). Returns true when the header was set. */
++function earlyHints(res, links) {
++ const list = (links || []).filter((l) => l && l.href);
++ if (!list.length || !res || res.headersSent) return false;
++ const header = list.map((l) => '<' + l.href + '>; rel=preload' + (l.as ? '; as=' + l.as : '')).join(', ');
++ try { res.setHeader('Link', header); return true; } catch { return false; }
++}
++
++/**
++ * Express middleware: hint a page, never a file. A file request already knows its own URL, and the API is none of the
++ * browser's business.
++ */
++function hints() {
++ return function liveEarlyHints(req, res, next) {
++ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
++ const p = String(req.path || '');
++ if (p.startsWith('/api/') || p.startsWith('/shared/') || p.startsWith('/js/') || p.startsWith('/css/')) return next();
++ const accept = String(req.headers.accept || '');
++ if (accept && accept.indexOf('text/html') < 0) return next();
++ try { earlyHints(res, shellLinks()); } catch { /* a hint is never worth a page */ }
++ return next();
++ };
++}
++
++/**
++ * The shell the worker keeps: the shared files every Live page loads, by content hash. The worker asks for this once and
++ * puts the answers in its cache, so a later visit does not wait for the network at all.
++ */
++function manifest() {
++ const files = [
++ '/shared/navbar.js',
++ '/shared/theme-loader.js',
++ '/shared/web-runtime.js',
++ '/shared/footer.js',
++ '/shared/nav-icons.js',
++ ];
++ return { version: shared && shared.VERSION ? shared.VERSION : null, shell: files.map((f) => assets.versioned(f)).filter(Boolean) };
++}
++
++module.exports = { shellLinks, manifest, earlyHints, hints };
ddiff --git a/test/shell.test.js b/test/shell.test.js
nnew file mode 100644
iindex 00000000..8f626255
---- /dev/null
++++ b/test/shell.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++// The shell on the server's side (server/web/sw.js): the early hints (the page's Link header; never a 103 through nginx
++// 1.28, which takes it as the final answer) and the manifest the
++// shared worker reads to warm the shell by content hash.
++const assert = require('assert');
++const assets = require('../server/web/assets');
++// The shared files are served from the pinned openvibe-shared package (the server sets this at startup).
++try { assets.setSharedDir(require('openvibe-shared/files').dir); } catch { /* */ }
++const shell = require('../server/web/sw');
++
++function fakeRes() {
++ const res = { headersSent: false, headers: {}, hints: null };
++ res.writeEarlyHints = (h) => { res.hints = h; return true; };
++ res.setHeader = (k, v) => { res.headers[k] = v; };
++ return res;
++}
++
++{ // A page is hinted in its Link header; no 103 goes out (nginx 1.28 would send the page as the 103's body).
++ const res = fakeRes();
++ let nexted = false;
++ shell.hints()({ method: 'GET', path: '/', headers: { accept: 'text/html' } }, res, () => { nexted = true; });
++ assert.ok(nexted, 'the request goes on');
++ assert.strictEqual(res.hints, null, 'no 103');
++ const link = res.headers.Link || '';
++ assert.ok(link.indexOf('rel=preload') >= 0, 'the links are preloads');
++ assert.ok(/as=script/.test(link) && /as=style/.test(link), 'the scripts and the stylesheet are named');
++ assert.ok(res.headers.Link && res.headers.Link.indexOf('rel=preload') >= 0, 'the page carries the hint too');
++}
++
++{ // The API and the files are none of the browser's business: they know their own URL.
++ for (const p of ['/api/thing', '/shared/navbar.js', '/js/app.js', '/css/style.css']) {
++ const res = fakeRes();
++ shell.hints()({ method: 'GET', path: p, headers: { accept: '*/*' } }, res, () => { });
++ assert.ok(!res.hints && !res.headers.Link, p + ' is not hinted');
++ }
++ // A write is never hinted.
++ const res = fakeRes();
++ shell.hints()({ method: 'POST', path: '/', headers: { accept: 'text/html' } }, res, () => { });
++ assert.ok(!res.hints && !res.headers.Link, 'a write is not hinted');
++}
++
++{ // The manifest: the shell by content hash, so the worker warms the files the page already asks for.
++ const man = shell.manifest();
++ assert.ok(Array.isArray(man.shell) && man.shell.length >= 3, 'the shell is listed');
++ for (const u of man.shell) {
++ assert.ok(u.startsWith('/shared/'), u + ' is a shared file');
++ assert.ok(/\?v=[0-9a-f]{8,}$/.test(u), u + ' carries its content hash');
++ }
++ assert.ok(man.shell.some((u) => u.indexOf('/shared/navbar.js') === 0), 'the navbar is in the shell');
++}
++
++console.log('shell: ok');