openvibers/openvibe.host Commits
Serve Host cache headers from the shared cache-policy module (#4)
Changes
@@@ -19,6 +19,7 @@ const cookieParser = require('cookie-parser');const rateLimit = require('express-rate-limit');const contracts = require('openvibe-contracts');const sharedMetrics = require('openvibe-shared/metrics');++const cache = require('openvibe-shared/cache-policy');const { createIndexNow } = require('openvibe-shared/indexnow');const configLib = require('./config');@@@ -202,9 +203,9 @@ async function createApp(opts = {}) {// never reach this app's routes; http/tenant.js serves a deploy's own robots.txt/sitemap.xml, or a// generated one when the deploy ships none.const legalPaths = require('openvibe-shared/legal').PATHS;-- app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')++ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));-- app.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')++ app.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${['/', ...legalPaths].map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`));// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────@@@ -220,7 +221,7 @@ async function createApp(opts = {}) {setHeaders(res, filePath) {const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');const v = res.req && res.req.query && res.req.query.v;-- res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));},}));@@@ -232,14 +233,14 @@ async function createApp(opts = {}) {// dispatch above sends every tenant host to tenant.handle, so this never answers on a public host),// session-authenticated, membership-checked in tenant.preview, noindex and never cached.app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => {-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));Promise.resolve(tenant.preview(req, res)).catch(next);});// ── Dashboard ───────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));app.use((req, res) => {-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });res.status(404).type('html').send(renderPage({ title: 'Not found', body: pages.errorPage({ status: 404, message: 'There is no page at this address.' }), viewer: req.viewer, config, path: req.originalUrl }));});@@@ -248,7 +249,7 @@ async function createApp(opts = {}) {app.use((err, req, res, _next) => {log.error('[Host]', err && err.stack ? err.stack : err);if (res.headersSent) return;-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');});@@@ -6,6 +6,7 @@*/const express = require('express');const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');/** A refusal with a stable problem code (e.g. 404 'site.not_found'). */class ApiError extends Error {@@@ -52,7 +53,7 @@ function jsonBody(req, res, next) {/** Private, per-viewer responses: never stored by a shared cache. */function privateNoStore(res) {-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));res.vary('Cookie');res.vary('Authorization');}nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++/**++ * The dashboard's static assets take their Cache-Control from openvibe-shared/cache-policy (plan T11,++ * cache-policy): the exact `?v=<assetVersion(rel)>` the render layout hands out is immutable for a++ * year; a wrong-but-hex `?v=` and no `?v=` at all are the short window with stale-while-revalidate,++ * never immutable, so a changed file cannot be pinned by a query string that is not the real hash.++ */++const assert = require('assert');++const cache = require('openvibe-shared/cache-policy');++const { assetVersion } = require('../server/render/layout');++const { boot, check, done, DASHBOARD } = require('./stageb/boot');++++const ASSET = 'css/host.css';++++(async () => {++ const t = await boot();++ try {++ await check("the current ?v=<assetVersion> is 'public, max-age=31536000, immutable'", async () => {++ const r = await t.get(DASHBOARD, `/${ASSET}?v=${assetVersion(ASSET)}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers['cache-control'], cache.IMMUTABLE);++ assert.strictEqual(r.headers['cache-control'], 'public, max-age=31536000, immutable');++ });++++ await check("a wrong-but-hex ?v= is 'public, max-age=300, stale-while-revalidate=86400'", async () => {++ const r = await t.get(DASHBOARD, `/${ASSET}?v=deadbeefdeadbeef`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers['cache-control'], cache.assetHeaders(ASSET, { hashed: false }));++ assert.strictEqual(r.headers['cache-control'], 'public, max-age=300, stale-while-revalidate=86400');++ });++++ await check("no ?v= is 'public, max-age=300, stale-while-revalidate=86400'", async () => {++ const r = await t.get(DASHBOARD, `/${ASSET}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers['cache-control'], 'public, max-age=300, stale-while-revalidate=86400');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -19,6 +19,7 @@ const cookieParser = require('cookie-parser');
@@ -19,6 +19,7 @@ const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const sharedMetrics = require('openvibe-shared/metrics');
const sharedMetrics = require('openvibe-shared/metrics');
+const cache = require('openvibe-shared/cache-policy');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const configLib = require('./config');
@@ -202,9 +203,9 @@ async function createApp(opts = {}) {
@@ -202,9 +203,9 @@ async function createApp(opts = {}) {
// never reach this app's routes; http/tenant.js serves a deploy's own robots.txt/sitemap.xml, or a
// never reach this app's routes; http/tenant.js serves a deploy's own robots.txt/sitemap.xml, or a
// generated one when the deploy ships none.
// generated one when the deploy ships none.
const legalPaths = require('openvibe-shared/legal').PATHS;
const legalPaths = require('openvibe-shared/legal').PATHS;
- app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
+ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
.send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
- app.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')
+ app.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${['/', ...legalPaths].map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`));
.send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${['/', ...legalPaths].map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`));
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
@@ -220,7 +221,7 @@ async function createApp(opts = {}) {
@@ -220,7 +221,7 @@ async function createApp(opts = {}) {
setHeaders(res, filePath) {
setHeaders(res, filePath) {
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const v = res.req && res.req.query && res.req.query.v;
const v = res.req && res.req.query && res.req.query.v;
- res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
},
},
}));
}));
@@ -232,14 +233,14 @@ async function createApp(opts = {}) {
@@ -232,14 +233,14 @@ async function createApp(opts = {}) {
// dispatch above sends every tenant host to tenant.handle, so this never answers on a public host),
// dispatch above sends every tenant host to tenant.handle, so this never answers on a public host),
// session-authenticated, membership-checked in tenant.preview, noindex and never cached.
// session-authenticated, membership-checked in tenant.preview, noindex and never cached.
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => {
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => {
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
Promise.resolve(tenant.preview(req, res)).catch(next);
Promise.resolve(tenant.preview(req, res)).catch(next);
});
});
// ── Dashboard ───────────────────────────────────────────
// ── Dashboard ───────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use((req, res) => {
app.use((req, res) => {
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });
if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });
res.status(404).type('html').send(renderPage({ title: 'Not found', body: pages.errorPage({ status: 404, message: 'There is no page at this address.' }), viewer: req.viewer, config, path: req.originalUrl }));
res.status(404).type('html').send(renderPage({ title: 'Not found', body: pages.errorPage({ status: 404, message: 'There is no page at this address.' }), viewer: req.viewer, config, path: req.originalUrl }));
});
});
@@ -248,7 +249,7 @@ async function createApp(opts = {}) {
@@ -248,7 +249,7 @@ async function createApp(opts = {}) {
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
log.error('[Host]', err && err.stack ? err.stack : err);
log.error('[Host]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (res.headersSent) return;
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
});
});
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
/** A refusal with a stable problem code (e.g. 404 'site.not_found'). */
/** A refusal with a stable problem code (e.g. 404 'site.not_found'). */
class ApiError extends Error {
class ApiError extends Error {
@@ -52,7 +53,7 @@ function jsonBody(req, res, next) {
@@ -52,7 +53,7 @@ function jsonBody(req, res, next) {
/** Private, per-viewer responses: never stored by a shared cache. */
/** Private, per-viewer responses: never stored by a shared cache. */
function privateNoStore(res) {
function privateNoStore(res) {
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.vary('Cookie');
res.vary('Cookie');
res.vary('Authorization');
res.vary('Authorization');
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+/**
+ * The dashboard's static assets take their Cache-Control from openvibe-shared/cache-policy (plan T11,
+ * cache-policy): the exact `?v=<assetVersion(rel)>` the render layout hands out is immutable for a
+ * year; a wrong-but-hex `?v=` and no `?v=` at all are the short window with stale-while-revalidate,
+ * never immutable, so a changed file cannot be pinned by a query string that is not the real hash.
+ */
+const assert = require('assert');
+const cache = require('openvibe-shared/cache-policy');
+const { assetVersion } = require('../server/render/layout');
+const { boot, check, done, DASHBOARD } = require('./stageb/boot');
+
+const ASSET = 'css/host.css';
+
+(async () => {
+ const t = await boot();
+ try {
+ await check("the current ?v=<assetVersion> is 'public, max-age=31536000, immutable'", async () => {
+ const r = await t.get(DASHBOARD, `/${ASSET}?v=${assetVersion(ASSET)}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers['cache-control'], cache.IMMUTABLE);
+ assert.strictEqual(r.headers['cache-control'], 'public, max-age=31536000, immutable');
+ });
+
+ await check("a wrong-but-hex ?v= is 'public, max-age=300, stale-while-revalidate=86400'", async () => {
+ const r = await t.get(DASHBOARD, `/${ASSET}?v=deadbeefdeadbeef`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers['cache-control'], cache.assetHeaders(ASSET, { hashed: false }));
+ assert.strictEqual(r.headers['cache-control'], 'public, max-age=300, stale-while-revalidate=86400');
+ });
+
+ await check("no ?v= is 'public, max-age=300, stale-while-revalidate=86400'", async () => {
+ const r = await t.get(DASHBOARD, `/${ASSET}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers['cache-control'], 'public, max-age=300, stale-while-revalidate=86400');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });