From c1ece08f38fdc8d0819da6d321fad6aeca5405ff Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Fri, 1 Sep 2023 12:27:44 +0200 Subject: [PATCH] Add ETag middleware --- lib/www/server/api/middleware/etag/cache.js | 45 +++++++ .../api/middleware/etag/if-none-match.js | 39 ++++++ lib/www/server/api/middleware/etag/index.js | 7 + lib/www/server/api/middleware/etag/no-save.js | 7 + lib/www/server/api/middleware/etag/save.js | 8 ++ lib/www/server/api/middleware/etag/watch.js | 127 ++++++++++++++++++ lib/www/server/api/middleware/index.js | 3 +- 7 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 lib/www/server/api/middleware/etag/cache.js create mode 100644 lib/www/server/api/middleware/etag/if-none-match.js create mode 100644 lib/www/server/api/middleware/etag/index.js create mode 100644 lib/www/server/api/middleware/etag/no-save.js create mode 100644 lib/www/server/api/middleware/etag/save.js create mode 100644 lib/www/server/api/middleware/etag/watch.js diff --git a/lib/www/server/api/middleware/etag/cache.js b/lib/www/server/api/middleware/etag/cache.js new file mode 100644 index 0000000..0916e8a --- /dev/null +++ b/lib/www/server/api/middleware/etag/cache.js @@ -0,0 +1,45 @@ + +const cache = {}; + +function getCache (r) { + if (!r?.app?.locals) { + return cache; + } + + if (!r.app.locals.etags) { + r.app.locals.etags = {}; + } + + return r.app.locals.etags; +} + +function isCached (req) { + const cache = getCache(req); + + if (req.url in cache) { + const cached = cache[req.url]; + const etag = req.get("If-None-Match"); + if (etag && etag == cached.etag) { + return cached; + } + } + return; // undefined +} + +function saveResponse (res) { + if (res?.headersSent) { + const etag = res.get("ETag"); + if (etag && res.locals.saveEtag !== false) { + const cache = getCache(res); + const req = res.req; + console.log(`Saving ETag: ${req.method} ${req.url} → ${etag}`); + cache[req.url] = {etag, headers: res.getHeaders()}; + } + } +}; + +module.exports = { + getCache, + isCached, + saveResponse +}; diff --git a/lib/www/server/api/middleware/etag/if-none-match.js b/lib/www/server/api/middleware/etag/if-none-match.js new file mode 100644 index 0000000..eed1500 --- /dev/null +++ b/lib/www/server/api/middleware/etag/if-none-match.js @@ -0,0 +1,39 @@ +const { isCached } = require('./cache'); + +function isIdempotentMethod (method) { + const nonIdempotentMethods = [ + "POST", "PUT", "PATH", "DELETE" + ]; + + return !nonIdempotentMethods.includes(method.toUpperCase()); +}; + +function setHeaders(res, headers) { + for (let [key, value] of Object.entries(headers)) { + res.set(key, value); + } + return res; +} + + +function ifNoneMatch (req, res, next) { + + const cached = isCached(req); + if (cached) { + console.log("ETag match", req.url); + setHeaders(res, cached.headers); + if (req.method == "GET" || req.method == "HEAD") { + res.status(304).send(); + // No next() + } else if (!isIdempotentMethod(req.method)) { + res.status(412).send(); + } + } else { + // Either we didn't have this URL in the cache, or there was + // no If-None-Match header, or it didn't match the cached ETag. + // We let the request proceed normally. + next(); + } +} + +module.exports = ifNoneMatch; diff --git a/lib/www/server/api/middleware/etag/index.js b/lib/www/server/api/middleware/etag/index.js new file mode 100644 index 0000000..0392570 --- /dev/null +++ b/lib/www/server/api/middleware/etag/index.js @@ -0,0 +1,7 @@ + +module.exports = { + ifNoneMatch: require('./if-none-match'), + save: require('./save'), + noSave: require('./no-save'), + watch: require('./watch') +}; diff --git a/lib/www/server/api/middleware/etag/no-save.js b/lib/www/server/api/middleware/etag/no-save.js new file mode 100644 index 0000000..cf6f85c --- /dev/null +++ b/lib/www/server/api/middleware/etag/no-save.js @@ -0,0 +1,7 @@ + +function noSave (req, res, next) { + res.locals.saveEtag = false; + next(); +} + +module.exports = noSave; diff --git a/lib/www/server/api/middleware/etag/save.js b/lib/www/server/api/middleware/etag/save.js new file mode 100644 index 0000000..e8228ef --- /dev/null +++ b/lib/www/server/api/middleware/etag/save.js @@ -0,0 +1,8 @@ +const { saveResponse } = require('./cache'); + +function save (req, res, next) { + saveResponse(res); + next(); +} + +module.exports = save; diff --git a/lib/www/server/api/middleware/etag/watch.js b/lib/www/server/api/middleware/etag/watch.js new file mode 100644 index 0000000..26fac46 --- /dev/null +++ b/lib/www/server/api/middleware/etag/watch.js @@ -0,0 +1,127 @@ +const { getCache } = require('./cache'); +const { listen } = require('../../../lib/db/notify'); +const channels = require('../../../lib/db/channels'); + +const rels = [ + { + channels: [ "realtime" ], + urls: [ ] + }, + { + channels: [ "event" ], + urls: [ /^\/project\/([^\/]+)\/event/ ], + matches: [ "project" ] + }, + { + channels: [ "project" ], + urls: [ /^\/project\/([^\/]+)\// ], + matches: [ "project" ] + }, + { + channels: [ "preplot_lines", "preplot_points" ], + urls: [ /^\/project\/([^\/]+)\/line[\/?]?/ ], + matches: [ "project" ] + }, + { + channels: [ "planned_lines" ], + urls: [ /^\/project\/([^\/]+)\/plan[\/?]?/ ], + matches: [ "project" ] + }, + { + channels: [ "raw_lines", "raw_shots" ], + urls: [ /^\/project\/([^\/]+)\/sequence[\/?]?/ ], + matches: [ "project" ] + }, + { + channels: [ "final_lines", "final_shots" ], + urls: [ /^\/project\/([^\/]+)\/sequence[\/?]?/ ], + matches: [ "project" ] + }, + { + channels: [ "info" ], + urls: [ ], + matches: [ ], + callback (url, data) { + if (data.payload?.table == "info") { + const rx = /^\/project\/([^\/]+)\/info\/([^\/?]+)[\/?]?/; + const match = url.match(rx); + if (match) { + if (match[1] == data.payload.pid) { + if (match[2] == data.payload?.old?.key || match[2] == data.payload?.new?.key) { + return true; + } + } + } + } + return false; + } + }, + { + channels: [ "queue_items" ], + urls: [ ] + }, +] + +function invalidateCache (data, cache) { + return new Promise((resolve, reject) => { + const channel = data.channel; + const project = data.payload.pid; + const operation = data.payload.operation; + const table = data.payload.table; + const fields = { channel, project, operation, table }; + + for (let rel of rels) { + if (rel.channels.includes(channel)) { + for (let url of rel.urls) { + for (let [key, data] of Object.entries(cache)) { + const match = key.match(url)?.slice(1); + if (match) { + if (rel.matches) { + if (rel.matches.every( (field, idx) => match[idx] == fields[field] )) { + console.log("DELETE ENTRY (MATCHES)", key); + delete cache[key]; + } + } else { + // Delete unconditionally + console.log("DELETE ENTRY (UNCONDITIONAL)", key); + delete cache[key]; + } + } + + } + } + + if (rel.callback) { + for (let key of Object.keys(cache)) { + if (rel.callback(key, data)) { + console.log("DELETE ENTRY (CALLBACK)", key); + delete cache[key]; + } + } + } + + } + } + + resolve(); + }); +} + +async function watch (app) { + if (!app.locals?.etags) { + app.locals.etags = {}; + } + + function etagWatch (data) { + invalidateCache(data, app.locals.etags); + } + try { + const client = await listen(channels, etagWatch); + console.log("ETag watch installed", client); + } catch (err) { + console.error(err); + console.log("ETag watch not installed"); + } +} + +module.exports = watch; diff --git a/lib/www/server/api/middleware/index.js b/lib/www/server/api/middleware/index.js index 9faa039..3eb340c 100644 --- a/lib/www/server/api/middleware/index.js +++ b/lib/www/server/api/middleware/index.js @@ -15,5 +15,6 @@ module.exports = { info: require('./info'), meta: require('./meta'), openapi: require('./openapi'), - rss: require('./rss') + rss: require('./rss'), + etag: require('./etag') };