From 3b84e4938380da3108401612dd3f14e26cfd505c Mon Sep 17 00:00:00 2001 From: Sebastian Frank Date: Wed, 25 Feb 2026 17:35:10 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20enhance=20SSR=20cache=20man?= =?UTF-8?q?agement=20with=20dependency=20tracking=20and=20entry-level=20in?= =?UTF-8?q?validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/collections/ssr.yml | 9 ++++ api/hooks/clear_cache.js | 8 ++- api/hooks/lib/helper.js | 14 ------ api/hooks/lib/ssr-server.js | 27 ++++++++++ api/hooks/lib/utils.js | 99 +++++++++++-------------------------- api/hooks/ssr/get_read.js | 20 ++++++++ package.json | 4 +- tsconfig.json | 1 + 8 files changed, 96 insertions(+), 86 deletions(-) delete mode 100644 api/hooks/lib/helper.js diff --git a/api/collections/ssr.yml b/api/collections/ssr.yml index 97565a5..64037a7 100644 --- a/api/collections/ssr.yml +++ b/api/collections/ssr.yml @@ -21,6 +21,7 @@ meta: - source: path filter: true - source: validUntil + - dependencies permissions: public: @@ -64,3 +65,11 @@ fields: label: de: Gültig bis en: Valid until + + - name: dependencies + type: string[] + index: [single] + meta: + label: + de: Abhängigkeiten + en: Dependencies diff --git a/api/hooks/clear_cache.js b/api/hooks/clear_cache.js index 7491038..d14d10a 100644 --- a/api/hooks/clear_cache.js +++ b/api/hooks/clear_cache.js @@ -1,5 +1,11 @@ var utils = require("./lib/utils") ;(function () { - utils.clearSSRCache() + const col = context.collection() + const collectionName = col && col.name ? col.name : null + const req = context.request() + const method = req.method + const entryId = (context.data && !Array.isArray(context.data) && context.data.id) || req.param("id") || null + + utils.clearSSRCache(collectionName, entryId, method) })() diff --git a/api/hooks/lib/helper.js b/api/hooks/lib/helper.js deleted file mode 100644 index a142a67..0000000 --- a/api/hooks/lib/helper.js +++ /dev/null @@ -1,14 +0,0 @@ -const generateUrlString = (text) => { - if (text) { - return text - .replace(/[^a-zA-Z0-9 \/]/g, "") - .replace(/\s/g, "-") - .toLowerCase() - } - - return "" -} - -module.exports = { - generateUrlString, -} diff --git a/api/hooks/lib/ssr-server.js b/api/hooks/lib/ssr-server.js index d8a83a9..80cd745 100644 --- a/api/hooks/lib/ssr-server.js +++ b/api/hooks/lib/ssr-server.js @@ -13,6 +13,10 @@ const { apiSsrBaseURL, ssrPublishCheckCollections } = require("../config") function ssrRequest(cacheKey, endpoint, query, options) { let url = endpoint + (query ? "?" + query : "") + // track which collections/entries contribute to this SSR render + // endpoint may contain path segments (e.g. "content/abc123") or query strings + const collectionName = endpoint.split("?")[0].split("/")[0] + if (ssrPublishCheckCollections?.includes(endpoint)) { // @ts-ignore let validUntil = context.ssrCacheValidUntil @@ -63,6 +67,29 @@ function ssrRequest(cacheKey, endpoint, query, options) { // json is go data structure and incompatible with js, so we need to convert it const r = { data: JSON.parse(JSON.stringify(json)), count: count } + // track dependencies: "col:id" for single-entry, "col:*" for list queries + // @ts-ignore – dynamic property set by get_read.js + if (context.ssrDeps) { + let entryId = null + + if (!Array.isArray(r.data) && r.data && r.data.id) { + // direct ID lookup (COLLECTION/ID) – API returned single object + entryId = r.data.id + } else if (options?.limit === 1 && Array.isArray(r.data) && r.data.length === 1 && r.data[0] && r.data[0].id) { + // filter-based detail query with limit:1 + entryId = r.data[0].id + } + + if (entryId) { + // @ts-ignore + context.ssrDeps[collectionName + ":" + entryId] = true + } else { + // list query – any change to this collection affects this page + // @ts-ignore + context.ssrDeps[collectionName + ":*"] = true + } + } + // @ts-ignore context.ssrCache[cacheKey] = r diff --git a/api/hooks/lib/utils.js b/api/hooks/lib/utils.js index 322cfc7..a16bf5e 100644 --- a/api/hooks/lib/utils.js +++ b/api/hooks/lib/utils.js @@ -1,5 +1,3 @@ -const { cryptchaSiteId } = require("../config-client") - /** * * @param {any} str @@ -9,81 +7,44 @@ function log(str) { } /** - * convert object to string - * @param {any} obj object - * @returns {string} - */ -function obj2str(obj) { - if (Array.isArray(obj)) { - return JSON.stringify( - obj.map(function (idx) { - return obj2str(idx) - }) - ) - } else if (typeof obj === "object" && obj !== null) { - var elements = Object.keys(obj) - .sort() - .map(function (key) { - var val = obj2str(obj[key]) - if (val) { - return key + ":" + val - } - }) - - var elementsCleaned = [] - for (var i = 0; i < elements.length; i++) { - if (elements[i]) elementsCleaned.push(elements[i]) - } - - return "{" + elementsCleaned.join("|") + "}" - } - - if (obj) return obj -} - -/** - * clear SSR cache + * clear SSR cache – entry-level invalidation + * + * Dependencies are stored as strings: "col:id" (detail) or "col:*" (list). + * - POST (new entry): only list deps affected → delete where "col:*" + * - PUT/DELETE (entry): detail + list deps affected → delete where "col:id" OR "col:*" + * - No args: full wipe (manual clear) + * + * @param {string} [collectionName] - collection name + * @param {string} [entryId] - specific entry ID (for PUT/DELETE) + * @param {string} [method] - HTTP method (POST/PUT/DELETE) * @returns {number} */ -function clearSSRCache() { - var info = context.db.deleteMany("ssr", {}) - context.response.header("X-SSR-Cleared", info.removed) - return info.removed -} +function clearSSRCache(collectionName, entryId, method) { + /** @type {any} */ + let filter = {} + const m = method ? method.toUpperCase() : "" -function cryptchaCheck() { - const solutionId = context.data._sId - const solution = context.data._s - - const body = JSON.stringify({ solution: solution }) - console.log(body) - const resp = context.http.fetch( - "https://cryptcha.webmakers.de/api/command?siteId=" + - cryptchaSiteId + - "&cmd=check&clear=1&solutionId=" + - solutionId, - { - method: "POST", - body, - headers: { "Content-Type": "application/json" }, - } - ) - const j = resp.body.json() - - const corrent = j.status == "ok" && resp.status == 200 - if (!corrent) { - throw { - status: 400, - log: false, - error: "incorrect data", + if (collectionName && entryId && (m === "PUT" || m === "DELETE")) { + // entry updated or deleted: invalidate pages that reference this specific entry OR list this collection + filter = { + $or: [{ dependencies: collectionName + ":" + entryId }, { dependencies: collectionName + ":*" }], } + } else if (collectionName) { + // new entry (POST) or unknown method: invalidate all pages that list this collection + filter = { dependencies: collectionName + ":*" } } - return true + // else: no args → full wipe (empty filter) + + // @ts-ignore – filter uses MongoDB operators not in DbReadOptions type + const info = context.db.deleteMany("ssr", { filter: filter }) + context.response.header("X-SSR-Cleared", info.removed) + if (collectionName) { + context.response.header("X-SSR-Cleared-Collection", collectionName) + } + return info.removed } module.exports = { log, clearSSRCache, - obj2str, - cryptchaCheck, } diff --git a/api/hooks/ssr/get_read.js b/api/hooks/ssr/get_read.js index 6178211..b2ebdb7 100644 --- a/api/hooks/ssr/get_read.js +++ b/api/hooks/ssr/get_read.js @@ -120,6 +120,8 @@ const { ssrRequest } = require("../lib/ssr-server") } else { // @ts-ignore context.ssrCache = {} + // @ts-ignore – tracks dependencies as { "col:id": true, "col:*": true } + context.ssrDeps = {} // @ts-ignore context.ssrRequest = ssrRequest @@ -165,6 +167,22 @@ const { ssrRequest } = require("../lib/ssr-server") tpl = tpl.replace("", head) tpl = tpl.replace("", html) tpl = tpl.replace("", error ? "" : "") + + // Deduplicate deps: if "col:*" exists, drop all "col:" for that collection + // @ts-ignore – ssrDeps is set dynamically above + var depsKeys = context.ssrDeps ? Object.keys(context.ssrDeps) : [] + /** @type {{[key: string]: boolean}} */ + var wildcardCols = {} + depsKeys.forEach(function (k) { + if (k.endsWith(":*")) wildcardCols[k.split(":")[0]] = true + }) + depsKeys = depsKeys.filter(function (k) { + if (k.endsWith(":*")) return true + return !wildcardCols[k.split(":")[0]] + }) + + // @ts-ignore – append deps for debugging + comment += ", deps: [" + depsKeys.join(", ") + "]" tpl = tpl.replace("", comment ? "" : "") if (cacheIt && !noCache) { @@ -174,6 +192,8 @@ const { ssrRequest } = require("../lib/ssr-server") content: tpl, // @ts-ignore validUntil: context.ssrCacheValidUntil, + // dependency strings: "col:id" for detail, "col:*" for list (deduplicated) + dependencies: depsKeys, }) } diff --git a/package.json b/package.json index 91fc970..e521a21 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "start:ssr": "SSR=1 node scripts/esbuild-wrapper.js start", "build": "node scripts/esbuild-wrapper.js build", "build:admin": "node scripts/esbuild-wrapper.js build esbuild.config.admin.js", - "build:server": "node scripts/esbuild-wrapper.js build esbuild.config.server.js && babel --config-file ./babel.config.server.json _temp/app.server.js -o _temp/app.server.babeled.js && esbuild _temp/app.server.babeled.js --outfile=api/hooks/lib/app.server.js --bundle --sourcemap --platform=node", + "build:server": "node scripts/esbuild-wrapper.js build esbuild.config.server.js && babel --config-file ./babel.config.server.json _temp/app.server.js -o _temp/app.server.babeled.js && esbuild _temp/app.server.babeled.js --outfile=api/hooks/lib/app.server.js --bundle --sourcemap --platform=node --banner:js='// @ts-nocheck'", "test": "playwright test", "test:e2e": "playwright test tests/e2e", "test:api": "playwright test tests/api", @@ -55,4 +55,4 @@ "svelte-i18n": "^4.0.1" }, "packageManager": "yarn@4.7.0" -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1d5e1ae..2edf5e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@tsconfig/svelte/tsconfig.json", "include": ["frontend/src/**/*", "types/**/*", "./../../cms/tibi-types", "api/**/*"], + "exclude": ["**/app.server.js", "**/app.server.js.map", "_temp/**"], "compilerOptions": { "module": "esnext", "typeRoots": ["./node_modules/@types", "./types"],