openvibers/openvibe.live Commits
The shell's cache and the early hints: Live goes first (#732)
Changes
@@@ -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'),};}@@@ -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 fires.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),nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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');
@@ -88,6 +88,9 @@
@@ -88,6 +88,9 @@
// injected rules broke the hash-based report-only CSP on every page view (11-03: a /api/csp-report POST
// 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).
// each load; Chrome ignores 'inline-speculation-rules' beside hashes).
speculate: false,
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'),
onLogout: () => call('logout'),
};
};
}
}
@@ -426,6 +426,9 @@ app.use((req, res, next) => {
@@ -426,6 +426,9 @@ app.use((req, res, next) => {
}
}
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
* 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
* 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
@@ -621,6 +624,12 @@ console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser fi
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(sharedServePath, 'openvibe-sw.js'));
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.
// The share cards (og:image on every page), before the browser-file allowlist below.
require('./web/share-cards').register(app);
require('./web/share-cards').register(app);
// Serve only the files the package lists for sites (scripts, stylesheets, fonts, images: server/web/shared-files.js),
// Serve only the files the package lists for sites (scripts, stylesheets, fonts, images: server/web/shared-files.js),
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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');