feat: enhance SSR cache management with dependency tracking and entry-level invalidation

This commit is contained in:
2026-02-25 17:35:10 +00:00
parent 3886eb9f34
commit 3b84e49383
8 changed files with 96 additions and 86 deletions

View File

@@ -21,6 +21,7 @@ meta:
- source: path - source: path
filter: true filter: true
- source: validUntil - source: validUntil
- dependencies
permissions: permissions:
public: public:
@@ -64,3 +65,11 @@ fields:
label: label:
de: Gültig bis de: Gültig bis
en: Valid until en: Valid until
- name: dependencies
type: string[]
index: [single]
meta:
label:
de: Abhängigkeiten
en: Dependencies

View File

@@ -1,5 +1,11 @@
var utils = require("./lib/utils") var utils = require("./lib/utils")
;(function () { ;(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)
})() })()

View File

@@ -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,
}

View File

@@ -13,6 +13,10 @@ const { apiSsrBaseURL, ssrPublishCheckCollections } = require("../config")
function ssrRequest(cacheKey, endpoint, query, options) { function ssrRequest(cacheKey, endpoint, query, options) {
let url = endpoint + (query ? "?" + query : "") 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)) { if (ssrPublishCheckCollections?.includes(endpoint)) {
// @ts-ignore // @ts-ignore
let validUntil = context.ssrCacheValidUntil 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 // 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 } 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 // @ts-ignore
context.ssrCache[cacheKey] = r context.ssrCache[cacheKey] = r

View File

@@ -1,5 +1,3 @@
const { cryptchaSiteId } = require("../config-client")
/** /**
* *
* @param {any} str * @param {any} str
@@ -9,81 +7,44 @@ function log(str) {
} }
/** /**
* convert object to string * clear SSR cache entry-level invalidation
* @param {any} obj object *
* @returns {string} * Dependencies are stored as strings: "col:id" (detail) or "col:*" (list).
*/ * - POST (new entry): only list deps affected → delete where "col:*"
function obj2str(obj) { * - PUT/DELETE (entry): detail + list deps affected → delete where "col:id" OR "col:*"
if (Array.isArray(obj)) { * - No args: full wipe (manual clear)
return JSON.stringify( *
obj.map(function (idx) { * @param {string} [collectionName] - collection name
return obj2str(idx) * @param {string} [entryId] - specific entry ID (for PUT/DELETE)
}) * @param {string} [method] - HTTP method (POST/PUT/DELETE)
)
} 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
* @returns {number} * @returns {number}
*/ */
function clearSSRCache() { function clearSSRCache(collectionName, entryId, method) {
var info = context.db.deleteMany("ssr", {}) /** @type {any} */
let filter = {}
const m = method ? method.toUpperCase() : ""
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 + ":*" }
}
// 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) context.response.header("X-SSR-Cleared", info.removed)
if (collectionName) {
context.response.header("X-SSR-Cleared-Collection", collectionName)
}
return info.removed return info.removed
} }
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",
}
}
return true
}
module.exports = { module.exports = {
log, log,
clearSSRCache, clearSSRCache,
obj2str,
cryptchaCheck,
} }

View File

@@ -120,6 +120,8 @@ const { ssrRequest } = require("../lib/ssr-server")
} else { } else {
// @ts-ignore // @ts-ignore
context.ssrCache = {} context.ssrCache = {}
// @ts-ignore tracks dependencies as { "col:id": true, "col:*": true }
context.ssrDeps = {}
// @ts-ignore // @ts-ignore
context.ssrRequest = ssrRequest context.ssrRequest = ssrRequest
@@ -165,6 +167,22 @@ const { ssrRequest } = require("../lib/ssr-server")
tpl = tpl.replace("<!--HEAD-->", head) tpl = tpl.replace("<!--HEAD-->", head)
tpl = tpl.replace("<!--HTML-->", html) tpl = tpl.replace("<!--HTML-->", html)
tpl = tpl.replace("<!--SSR.ERROR-->", error ? "<!--" + error + "-->" : "") tpl = tpl.replace("<!--SSR.ERROR-->", error ? "<!--" + error + "-->" : "")
// Deduplicate deps: if "col:*" exists, drop all "col:<id>" 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("<!--SSR.COMMENT-->", comment ? "<!--" + comment + "-->" : "") tpl = tpl.replace("<!--SSR.COMMENT-->", comment ? "<!--" + comment + "-->" : "")
if (cacheIt && !noCache) { if (cacheIt && !noCache) {
@@ -174,6 +192,8 @@ const { ssrRequest } = require("../lib/ssr-server")
content: tpl, content: tpl,
// @ts-ignore // @ts-ignore
validUntil: context.ssrCacheValidUntil, validUntil: context.ssrCacheValidUntil,
// dependency strings: "col:id" for detail, "col:*" for list (deduplicated)
dependencies: depsKeys,
}) })
} }

View File

@@ -12,7 +12,7 @@
"start:ssr": "SSR=1 node scripts/esbuild-wrapper.js start", "start:ssr": "SSR=1 node scripts/esbuild-wrapper.js start",
"build": "node scripts/esbuild-wrapper.js build", "build": "node scripts/esbuild-wrapper.js build",
"build:admin": "node scripts/esbuild-wrapper.js build esbuild.config.admin.js", "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": "playwright test",
"test:e2e": "playwright test tests/e2e", "test:e2e": "playwright test tests/e2e",
"test:api": "playwright test tests/api", "test:api": "playwright test tests/api",

View File

@@ -2,6 +2,7 @@
"extends": "@tsconfig/svelte/tsconfig.json", "extends": "@tsconfig/svelte/tsconfig.json",
"include": ["frontend/src/**/*", "types/**/*", "./../../cms/tibi-types", "api/**/*"], "include": ["frontend/src/**/*", "types/**/*", "./../../cms/tibi-types", "api/**/*"],
"exclude": ["**/app.server.js", "**/app.server.js.map", "_temp/**"],
"compilerOptions": { "compilerOptions": {
"module": "esnext", "module": "esnext",
"typeRoots": ["./node_modules/@types", "./types"], "typeRoots": ["./node_modules/@types", "./types"],