Files
apairon 0004bc98a1 fix(ssr): Cache-Schreibfehler schlucken, App-Start absichern
Dieselbe Vorlage, dieselbe Auslieferung: `context.db.create("ssr", …)` ist
ein Insert, mehrere parallele Anfragen nach einer Invalidierung erzeugen
"duplicate key", und die Verlierer antworten mit 500 — ohne #appContainer,
also bleibt die Seite beim Besucher leer. Der Fehler wird geschluckt, die
Seite geht raus.

Der Block ist byte-gleich zu dem im event-website-tibi-2026-Projekt. Der
Starter ist die Vorlage; zwei Varianten desselben Fixes wären genau die
Drift, die hier nicht stattfinden darf.

Dazu der Einstiegspunkt, der denselben Fehlerklasse-Vorfall hat:

    setupI18n().then(() => { hydrate(App, { target: appContainer }) })

Ein Fehler beim Laden der Locale-Datei oder ein werfendes hydrate()
lässt das ausgelieferte HTML stehen — ohne Signal, ohne Logeintrag, und für
einen Test nicht unterscheidbar von "noch nicht fertig". Jetzt steht der
Render in try/catch, der Fehler wird geloggt, und #appContainer bekommt
data-app-ready="error". Tests warten auf den Wert "true" und nennen bei
"error" die Ursache mit, statt still zu timeouten.
2026-10-05 16:40:58 +00:00

247 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const utils = require("../lib/utils")
const { release } = require("../config-client")
const { ssrValidatePath } = require("../config")
const { ssrRequest } = require("../lib/ssr-server")
const APP_SERVER_MODULE_PATH = "../lib/app.server"
;(function () {
var request = context.request()
var trackingCall = request.header("x-ssr-skip")
if (trackingCall) {
// skip tracking
// no cache header
context.response.header("Cache-Control", "no-cache, no-store, must-revalidate")
throw {
status: parseInt(trackingCall),
html: "",
log: false,
}
}
let url = request.query("url") || request.header("x-ssr-url") || "/"
const noCache = request.query("noCache")
const trace_id = context.debug.sentryTraceId()
/**
* @param {string} content
*/
function addSentryTrace(content) {
return content.replace("</head>", '<meta name="sentry-trace" content="' + trace_id + '" /></head>')
}
context.response.header("sentry-trace", trace_id)
const auth = context.user.auth()
if (auth && auth.role == 0) {
} else if (url) {
/** @type {Date} */ // @ts-ignore
context.ssrCacheValidUntil = null
var comment = ""
url = url.split("?")[0]
comment += "url: " + url
if (url && url.length > 1) {
url = url.replace(/\/$/, "")
}
if (url == "/index" || !url) {
url = "/" // see .htaccess
}
function useCache(/** @type {string} */ _url) {
var cache =
!noCache &&
context.db.find("ssr", {
filter: {
path: _url,
},
})
if (cache && cache.length) {
// check that entry is still allowed to be published
const validUntil = cache[0].validUntil ? new Date(cache[0].validUntil.unixMilli()) : null
if (!validUntil || validUntil > new Date()) {
// use cache
context.response.header("X-SSR-Cache", "true")
throw {
status: 200,
log: false,
html: addSentryTrace(cache[0].content),
}
} else {
// cache is invalid, delete it
context.response.header("X-SSR-Cache", "invalid")
context.db.delete("ssr", cache[0].id)
}
}
}
// validate url
var status = 200
var pNorender = false
var pNotfound = false
const pR = ssrValidatePath(url)
if (pR === -1) {
pNotfound = true
comment += ", notFound"
} else if (!pR) {
pNorender = true
comment += ", noRender"
} else if (typeof pR === "string") {
url = pR
comment += ", cache url: " + url
}
if (noCache) {
comment += ", noCache"
}
if (!pNorender && !pNotfound) {
// check if we have a cache
useCache(url)
}
let head = ""
let html = ""
let error = ""
comment += ", path: " + url
var cacheIt = false
if (pNorender) {
html = "<!-- NO SSR RENDERING -->"
} else if (pNotfound) {
status = 404
html = "404 NOT FOUND"
} else {
// @ts-ignore
context.ssrCache = {}
// @ts-ignore – tracks dependencies as { "col:id": true, "col:*": true }
context.ssrDeps = {}
// @ts-ignore
context.ssrRequest = ssrRequest
try {
// if error, output plain html without prerendering
// @ts-ignore
const app = require(APP_SERVER_MODULE_PATH)
const rendered = app.default.render({
url: url,
})
head = rendered.head
html = rendered.html
head +=
"\n\n" +
"<script>window.__SSR_CACHE__ = " +
// @ts-ignore
JSON.stringify(context.ssrCache) +
"</script>"
// status from webapp
// @ts-ignore
if (context.is404) {
status = 404
} else {
cacheIt = true
}
} catch (/** @type {any} */ e) {
utils.log(e.message)
utils.log(e.stack)
error = "error: " + e.message + "\n\n" + e.stack
// utils.log(e)
// for (var property in e) {
// utils.log(property + ": " + e[property])
// }
// error = JSON.stringify(e)
}
}
var tpl = context.fs.readFile("templates/spa.html")
// Extract language from URL for <html lang="..."> (before other replacements)
var langMatch = url.match(/^\/(de|en)(\/|$)/)
var pageLang = langMatch ? langMatch[1] : "de"
tpl = tpl.replace(/<html lang="[^"]*">/, '<html lang="' + pageLang + '">')
tpl = tpl.replace("<!--HEAD-->", head)
tpl = tpl.replace("<!--HTML-->", html)
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 + "-->" : "")
if (cacheIt && !noCache) {
// save cache
//
// `context.db.create` ist ein Insert, kein Upsert. Nach einer
// Invalidierung rendern mehrere Anfragen dieselbe URL gleichzeitig,
// und alle schreiben denselben Eintrag. Der Unique-Index auf `path`
// lässt genau einen gewinnen; die Verlierer bekommen "duplicate
// key".
//
// Das wird hier bewusst geschluckt. Der Eintrag existiert dann
// schon — der Gewinner hat ihn geschrieben, es fehlt nichts. Und
// die Seite ist ohnehin fertig gerendert, `tpl` steht bereit.
//
// Wichtig ist das aus zwei Gründen: Ein durchschlagender Fehler
// liefert eine gute Seite als HTTP 500 aus, und ein
// 500-Dokument hat kein #appContainer — die Seite bleibt beim
// Besucher dann für immer leer, weil hydrate() nichts zum
// Einhängen findet.
//
// Was hier nicht passiert: dass zweimal gerendert wird. Dafür bräuchte
// es Koordination über Anfragen hinweg, und ein Hook hat keinen
// geteilten Zustand. Das bleibt erstmal so — ein Ansturm kostet
// Rechenzeit, keine Seite.
try {
context.db.create("ssr", {
path: url,
content: tpl,
// @ts-ignore
validUntil: context.ssrCacheValidUntil,
// dependency strings: "col:id" for detail, "col:*" for list (deduplicated)
dependencies: depsKeys,
})
} catch (e) {
utils.log("ssr cache: Eintrag für " + url + " wurde parallel geschrieben, Anfrage ohne eigenen Cache-Eintrag")
}
}
throw {
status: status,
log: false,
html: addSentryTrace(tpl),
}
} else {
// only admins are allowed
throw {
status: 403,
message: "invalid auth",
auth: auth,
release: release,
}
}
})()