openvibers/openvibe.deals Commits
Route every server cache header through openvibe-shared cache-policy (#2)
Changes
@@@ -45,6 +45,7 @@ const { createDealsReadiness } = require('./observability');const { createActorLimits } = require('./http/actor-limits');const { createWorker } = require('./worker');const { assetVersion } = require('./render/layout');++const cache = require('openvibe-shared/cache-policy');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;@@@ -162,7 +163,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) }));},}));@@@ -183,7 +184,7 @@ async function createApp(opts = {}) {app.use((err, req, res, _next) => {log.error('[Deals]', 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.');});@@@ -17,11 +17,12 @@const express = require('express');const seo = require('openvibe-publishing/seo');const sharedSeo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');function createDiscoveryRoutes({ config, store, publication, listings }) {const router = express.Router();const abs = (p) => seo.canonicalUrl(config.baseUrl, p);-- const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);++ const xml = (res, body) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);async function offerEntries() {return Promise.all((await listings.indexable()).map(async (o) => {@@@ -45,11 +46,11 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {'# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/auth/', '/api/', '/internal/'] }),].join('\n');-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);});router.get('/llms.txt', (_req, res) => {-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({name: 'OpenVibe.Deals',summary: 'Deals submitted and voted on by the OpenVibe community or imported from registered sources, with the source and observation time of every price.',details: 'A price on this site is an observation: it has a source and an observed_at time, and it is only claimed "as of" that time. Offers whose latest observation is older than the freshness window are marked stale and are noindex; expired offers are noindex; a price nobody stated is shown as "not stated" and omitted from structured data (never 0). Every deal page has a JSON twin at <deal URL>.json with the full observation history, sources, votes and indexability reasons. Imported and AI-assisted text is noindex until a person reviews it.',@@@ -6,6 +6,7 @@*/const express = require('express');const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');const { ApiError } = require('../domain/values');/** Wrap a JSON handler: its return value is the body; errors become problems. */@@@ -35,7 +36,7 @@ function jsonBody(req, res, next) {/** Private, per-viewer responses: never stored by a shared cache, never indexed. */function privateNoStore(res) {-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));res.vary('Cookie');res.vary('Authorization');}@@@ -24,6 +24,7 @@ const express = require('express');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const seo = require('openvibe-publishing/seo');++const cache = require('openvibe-shared/cache-policy');const ssr = require('openvibe-publishing/ssr');const { renderPage } = require('../render/layout');const views = require('../render/views');@@@ -48,8 +49,8 @@ function createPages(ctx) {function cacheHeaders(req, res, { cacheable, robots, status }) {res.vary('Cookie');res.vary('Authorization');-- if (cacheable && req.viewer.kind === 'anonymous' && status === 200) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');-- else res.set('Cache-Control', 'private, no-store');++ if (cacheable && req.viewer.kind === 'anonymous' && status === 200) res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));++ else res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);}@@@ -197,7 +198,7 @@ function createPages(ctx) {if (!offer) { notFound(req, res); return null; }if (offer.merged_into) {const root = await reads.root(offer);-- res.set('Cache-Control', 'public, max-age=300');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));res.redirect(301, `${publication.offerPath(root)}${json ? '.json' : ''}`);return null;}@@@ -423,12 +424,12 @@ function createPages(ctx) {}const channel = () => ({ title: 'OpenVibe.Deals — new deals', link: publication.abs('/new'), description: 'New deals, each with its source and the time its price was observed.', language: 'en' });-- router.get('/feed.xml', async (_req, res) => res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, await feedItems())));++ router.get('/feed.xml', async (_req, res) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, await feedItems())));router.get('/atom.xml', async (_req, res) => {const items = await feedItems();-- res.type('application/atom+xml').set('Cache-Control', 'public, max-age=300').send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));++ res.type('application/atom+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));});-- router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));++ router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));return { router, notFound, messagePage };}nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++/**++ * Static-asset caching (openvibe-shared/cache-policy, used by the express.static setHeaders in++ * server/app.js): a URL whose `?v=` equals the render layout's assetVersion for that file is++ * content-addressed and immutable for a year; anything else — a wrong hash or no `?v=` at all —++ * is a short public window with a day of stale-while-revalidate.++ */++const assert = require('assert');++const { assetVersion } = require('../server/render/layout');++const { boot, check, done } = require('./helpers/boot');++++const ASSET = 'css/deals.css';++const IMMUTABLE = 'public, max-age=31536000, immutable';++const SHORT = 'public, max-age=300, stale-while-revalidate=86400';++++(async () => {++ const t = await boot();++ try {++ await check('the current ?v=<assetVersion> is served immutable for a year', async () => {++ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), IMMUTABLE);++ });++++ await check('a wrong-but-hex ?v= and no ?v= get the short public window', async () => {++ for (const suffix of ['?v=deadbeefdeadbeef', '']) {++ const r = await t.get(`/${ASSET}${suffix}`);++ assert.strictEqual(r.status, 200, suffix || 'no ?v=');++ assert.strictEqual(r.headers.get('cache-control'), SHORT, suffix || 'no ?v=');++ }++ });++ } finally {++ await t.close();++ }++ done();++})();
@@ -45,6 +45,7 @@ const { createDealsReadiness } = require('./observability');
@@ -45,6 +45,7 @@ const { createDealsReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
+const cache = require('openvibe-shared/cache-policy');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -162,7 +163,7 @@ async function createApp(opts = {}) {
@@ -162,7 +163,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) }));
},
},
}));
}));
@@ -183,7 +184,7 @@ async function createApp(opts = {}) {
@@ -183,7 +184,7 @@ async function createApp(opts = {}) {
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
log.error('[Deals]', err && err.stack ? err.stack : err);
log.error('[Deals]', 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.');
});
});
@@ -17,11 +17,12 @@
@@ -17,11 +17,12 @@
const express = require('express');
const express = require('express');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const sharedSeo = require('openvibe-shared/seo');
const sharedSeo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
function createDiscoveryRoutes({ config, store, publication, listings }) {
function createDiscoveryRoutes({ config, store, publication, listings }) {
const router = express.Router();
const router = express.Router();
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
- const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
+ const xml = (res, body) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
async function offerEntries() {
async function offerEntries() {
return Promise.all((await listings.indexable()).map(async (o) => {
return Promise.all((await listings.indexable()).map(async (o) => {
@@ -45,11 +46,11 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -45,11 +46,11 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
'# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',
'# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/auth/', '/api/', '/internal/'] }),
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/auth/', '/api/', '/internal/'] }),
].join('\n');
].join('\n');
- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);
});
});
router.get('/llms.txt', (_req, res) => {
router.get('/llms.txt', (_req, res) => {
- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
name: 'OpenVibe.Deals',
name: 'OpenVibe.Deals',
summary: 'Deals submitted and voted on by the OpenVibe community or imported from registered sources, with the source and observation time of every price.',
summary: 'Deals submitted and voted on by the OpenVibe community or imported from registered sources, with the source and observation time of every price.',
details: 'A price on this site is an observation: it has a source and an observed_at time, and it is only claimed "as of" that time. Offers whose latest observation is older than the freshness window are marked stale and are noindex; expired offers are noindex; a price nobody stated is shown as "not stated" and omitted from structured data (never 0). Every deal page has a JSON twin at <deal URL>.json with the full observation history, sources, votes and indexability reasons. Imported and AI-assisted text is noindex until a person reviews it.',
details: 'A price on this site is an observation: it has a source and an observed_at time, and it is only claimed "as of" that time. Offers whose latest observation is older than the freshness window are marked stale and are noindex; expired offers are noindex; a price nobody stated is shown as "not stated" and omitted from structured data (never 0). Every deal page has a JSON twin at <deal URL>.json with the full observation history, sources, votes and indexability reasons. Imported and AI-assisted text is noindex until a person reviews it.',
@@ -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');
const { ApiError } = require('../domain/values');
const { ApiError } = require('../domain/values');
/** Wrap a JSON handler: its return value is the body; errors become problems. */
/** Wrap a JSON handler: its return value is the body; errors become problems. */
@@ -35,7 +36,7 @@ function jsonBody(req, res, next) {
@@ -35,7 +36,7 @@ function jsonBody(req, res, next) {
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
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');
}
}
@@ -24,6 +24,7 @@ const express = require('express');
@@ -24,6 +24,7 @@ const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
+const cache = require('openvibe-shared/cache-policy');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
const views = require('../render/views');
const views = require('../render/views');
@@ -48,8 +49,8 @@ function createPages(ctx) {
@@ -48,8 +49,8 @@ function createPages(ctx) {
function cacheHeaders(req, res, { cacheable, robots, status }) {
function cacheHeaders(req, res, { cacheable, robots, status }) {
res.vary('Cookie');
res.vary('Cookie');
res.vary('Authorization');
res.vary('Authorization');
- if (cacheable && req.viewer.kind === 'anonymous' && status === 200) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');
- else res.set('Cache-Control', 'private, no-store');
+ if (cacheable && req.viewer.kind === 'anonymous' && status === 200) res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
+ else res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
}
}
@@ -197,7 +198,7 @@ function createPages(ctx) {
@@ -197,7 +198,7 @@ function createPages(ctx) {
if (!offer) { notFound(req, res); return null; }
if (!offer) { notFound(req, res); return null; }
if (offer.merged_into) {
if (offer.merged_into) {
const root = await reads.root(offer);
const root = await reads.root(offer);
- res.set('Cache-Control', 'public, max-age=300');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.redirect(301, `${publication.offerPath(root)}${json ? '.json' : ''}`);
res.redirect(301, `${publication.offerPath(root)}${json ? '.json' : ''}`);
return null;
return null;
}
}
@@ -423,12 +424,12 @@ function createPages(ctx) {
@@ -423,12 +424,12 @@ function createPages(ctx) {
}
}
const channel = () => ({ title: 'OpenVibe.Deals — new deals', link: publication.abs('/new'), description: 'New deals, each with its source and the time its price was observed.', language: 'en' });
const channel = () => ({ title: 'OpenVibe.Deals — new deals', link: publication.abs('/new'), description: 'New deals, each with its source and the time its price was observed.', language: 'en' });
- router.get('/feed.xml', async (_req, res) => res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, await feedItems())));
+ router.get('/feed.xml', async (_req, res) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, await feedItems())));
router.get('/atom.xml', async (_req, res) => {
router.get('/atom.xml', async (_req, res) => {
const items = await feedItems();
const items = await feedItems();
- res.type('application/atom+xml').set('Cache-Control', 'public, max-age=300').send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));
+ res.type('application/atom+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));
});
});
- router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));
+ router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));
return { router, notFound, messagePage };
return { router, notFound, messagePage };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+/**
+ * Static-asset caching (openvibe-shared/cache-policy, used by the express.static setHeaders in
+ * server/app.js): a URL whose `?v=` equals the render layout's assetVersion for that file is
+ * content-addressed and immutable for a year; anything else — a wrong hash or no `?v=` at all —
+ * is a short public window with a day of stale-while-revalidate.
+ */
+const assert = require('assert');
+const { assetVersion } = require('../server/render/layout');
+const { boot, check, done } = require('./helpers/boot');
+
+const ASSET = 'css/deals.css';
+const IMMUTABLE = 'public, max-age=31536000, immutable';
+const SHORT = 'public, max-age=300, stale-while-revalidate=86400';
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the current ?v=<assetVersion> is served immutable for a year', async () => {
+ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), IMMUTABLE);
+ });
+
+ await check('a wrong-but-hex ?v= and no ?v= get the short public window', async () => {
+ for (const suffix of ['?v=deadbeefdeadbeef', '']) {
+ const r = await t.get(`/${ASSET}${suffix}`);
+ assert.strictEqual(r.status, 200, suffix || 'no ?v=');
+ assert.strictEqual(r.headers.get('cache-control'), SHORT, suffix || 'no ?v=');
+ }
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();