feat: implement build version check and update build info handling

This commit is contained in:
2026-02-25 15:53:00 +00:00
parent f6f565bbcb
commit e13e696253
10 changed files with 186 additions and 66 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
api/hooks/lib/app.server*
api/hooks/lib/buildInfo.js
frontend/src/lib/buildInfo.ts
node_modules
media
tmp

View File

@@ -1,4 +1,5 @@
const release = "__PROJECT__.dirty"
const { gitHash, buildTime } = require("./lib/buildInfo")
const release = gitHash // overwritten by CI with PROJECT_RELEASE (Sentry-Release-String)
const originURL = "https://__PROJECT__.code.testversion.online"
const apiClientBaseURL = "/api/"
@@ -8,10 +9,12 @@ const cryptchaSiteId = "6628f06a0938460001505119"
// @ts-ignore
if (release && typeof context !== "undefined") {
context.response.header("X-Release", release)
context.response.header("X-Build-Time", buildTime)
}
module.exports = {
release,
buildTime,
apiClientBaseURL,
cryptchaSiteId,
originURL,

View File

@@ -1,20 +0,0 @@
{
"sourceMaps": "inline",
"inputSourceMap": true,
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": {
"version": "3",
"proposals": true
},
"targets": ">0.5%, IE 11, not dead",
"debug": true,
"forceAllTransforms": true
}
]
],
"plugins": []
}

View File

@@ -1,6 +1,36 @@
const fs = require("fs")
const { execSync } = require("child_process")
const postcssPlugin = require("esbuild-postcss")
// Resolve version at build time via git describe (tag + hash), fallback to env var or "dev"
let gitHash = "dev"
try {
gitHash = process.env.GIT_HASH || execSync("git describe --tags --always --dirty").toString().trim()
} catch (_) {
// .git not available (e.g. Docker build without .git context)
}
// Generate buildInfo module that can be imported by frontend code
function writeBuildInfo() {
const info = {
gitHash,
buildTime: new Date().toISOString(),
}
fs.writeFileSync(
__dirname + "/frontend/src/lib/buildInfo.ts",
`// AUTO-GENERATED by esbuild.config.js \u2013 do not edit\nexport const gitHash = ${JSON.stringify(info.gitHash)}\nexport const buildTime = ${JSON.stringify(info.buildTime)}\n`
)
// Write same buildInfo for backend hooks (X-Build-Time / X-Release headers)
fs.writeFileSync(
__dirname + "/api/hooks/lib/buildInfo.js",
`// AUTO-GENERATED by esbuild.config.js \u2013 do not edit\nmodule.exports = { gitHash: ${JSON.stringify(info.gitHash)}, buildTime: ${JSON.stringify(info.buildTime)} }\n`
)
}
// NOTE: writeBuildInfo() is NOT called here at top-level.
// It is called by esbuild-wrapper.js before each build.
// This prevents the server build from overwriting the frontend's timestamp
// (which would cause a version mismatch and spurious auto-reload).
const resolvePlugin = {
name: "resolvePlugin",
setup(build) {
@@ -63,7 +93,7 @@ const options = {
".ttf": "file",
},
sourcemap: true,
target: ["es2020", "chrome61", "firefox60", "safari11", "edge18"],
target: ["es2022", "chrome100", "firefox100", "safari15", "edge100"],
}
const bsMiddleware = []
@@ -83,6 +113,56 @@ if (process.argv[2] == "start") {
})
)
// Sentry tunnel proxy: forwards /_s requests to Sentry ingest to avoid ad-blockers
const https = require("https")
const http = require("http")
bsMiddleware.push(function (req, res, next) {
if (req.url !== "/_s" || req.method !== "POST") return next()
let body = []
req.on("data", (chunk) => body.push(chunk))
req.on("end", () => {
const payload = Buffer.concat(body).toString()
const firstLine = payload.split("\n")[0]
let envelope
try {
envelope = JSON.parse(firstLine)
} catch {
res.writeHead(400)
res.end()
return
}
const dsn = envelope.dsn || ""
let sentryUrl
try {
sentryUrl = new URL(dsn)
} catch {
res.writeHead(400)
res.end()
return
}
const projectId = sentryUrl.pathname.replace(/\//g, "")
const ingestUrl = `${sentryUrl.protocol}//${sentryUrl.host}/api/${projectId}/envelope/`
const transport = ingestUrl.startsWith("https") ? https : http
const proxyReq = transport.request(
ingestUrl,
{
method: "POST",
headers: { "Content-Type": "application/x-sentry-envelope" },
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 200)
proxyRes.pipe(res)
}
)
proxyReq.on("error", () => {
res.writeHead(502)
res.end()
})
proxyReq.write(payload)
proxyReq.end()
})
})
if (process.env.SSR) {
bsMiddleware.push(
createProxyMiddleware({
@@ -103,6 +183,7 @@ if (process.argv[2] == "start") {
module.exports = {
sveltePlugin: sveltePlugin,
resolvePlugin: resolvePlugin,
writeBuildInfo: writeBuildInfo,
options: options,
distDir,
watch: {
@@ -121,6 +202,20 @@ module.exports = {
...bsMiddleware,
require("connect-history-api-fallback")({
index: "/spa.html",
// Allow dots in URL paths (e.g. /version-3.7.1) to fall through to SPA,
// but only if they don't look like actual file requests.
rewrites: [
{
from: /\.(js|css|mjs|map|woff2?|ttf|eot|svg|png|jpe?g|gif|webp|ico|json|pdf)(\?.*)?$/i,
to: function (context) {
return context.parsedUrl.pathname
},
},
{
from: /./,
to: "/spa.html",
},
],
// verbose: true,
}),
],

View File

@@ -1,8 +0,0 @@
const config = require("./esbuild.config.js")
config.options.sourcemap = "inline"
config.options.minify = false
config.options.format = "iife"
config.options.outfile = __dirname + "/_temp/index.js"
module.exports = config

View File

@@ -1,6 +1,15 @@
const fs = require("fs")
const config = require("./esbuild.config.js")
const svelteConfig = require("./svelte.config")
// Server build must NOT overwrite buildInfo written by the frontend build,
// otherwise the server timestamp is newer → checkBuildVersion triggers spurious reload.
// Only generate if buildInfo.js doesn't exist yet (standalone server build).
if (!fs.existsSync(__dirname + "/api/hooks/lib/buildInfo.js")) {
config.writeBuildInfo()
}
config.writeBuildInfo = null
config.options.sourcemap = "inline"
config.options.minify = false
config.options.platform = "node"

View File

@@ -50,40 +50,22 @@ const publishLocation = (_p?: string) => {
}
if (typeof history !== "undefined") {
if (typeof Proxy !== "undefined") {
// modern browser
const historyApply = (
target: (this: any, ...args: readonly any[]) => unknown,
thisArg: any,
argumentsList: string | readonly any[]
) => {
publishLocation(argumentsList && argumentsList.length >= 2 && argumentsList[2])
Reflect.apply(target, thisArg, argumentsList)
}
history.pushState = new Proxy(history.pushState, {
apply: historyApply,
})
history.replaceState = new Proxy(history.replaceState, {
apply: historyApply,
})
} else {
// ie11
const pushStateFn = history.pushState
const replaceStateFn = history.replaceState
history.pushState = function (data: any, title: string, url?: string) {
publishLocation(url)
// @ts-ignore
return pushStateFn.apply(history, arguments)
}
history.replaceState = function (data: any, title: string, url?: string) {
publishLocation(url)
// @ts-ignore
return replaceStateFn.apply(history, arguments)
}
const historyApply = (
target: (this: any, ...args: readonly any[]) => unknown,
thisArg: any,
argumentsList: string | readonly any[]
) => {
publishLocation(argumentsList && argumentsList.length >= 2 && argumentsList[2])
Reflect.apply(target, thisArg, argumentsList)
}
history.pushState = new Proxy(history.pushState, {
apply: historyApply,
})
history.replaceState = new Proxy(history.replaceState, {
apply: historyApply,
})
} // else ssr -> no history handling
typeof window !== "undefined" &&

View File

@@ -0,0 +1,54 @@
/**
* Build-Version-Check: Auto-Reload when a newer build is detected on the server
*
* Every GET API response includes the header X-Build-Time with the server's build
* timestamp. If it's newer than the one embedded in the frontend bundle, a toast
* is shown and the page is automatically reloaded after a short delay.
*/
import { buildTime } from "./buildInfo"
import { addToast } from "./toast"
/** Prevents multiple triggers within the same page session */
let triggered = false
const RELOAD_DELAY_MS = 3000
const STORAGE_KEY = "__build_reload__"
/**
* Checks whether the server has a newer build than the current frontend bundle.
* Only runs in the browser (SSR-safe).
*
* @param serverBuildTime - ISO timestamp from the X-Build-Time response header
*/
export function checkBuildVersion(serverBuildTime: string | null | undefined): void {
if (!serverBuildTime || typeof window === "undefined") return
if (triggered) return
// Only react if the server build is actually newer
if (serverBuildTime <= buildTime) return
// Loop protection: if we already reloaded for this exact build, don't reload again
// (e.g. CDN/cache still serving old bundle)
try {
if (sessionStorage.getItem(STORAGE_KEY) === serverBuildTime) return
} catch (_) {
// sessionStorage not available (e.g. privacy mode) continue anyway
}
triggered = true
// Show toast notification
addToast("New version available page will refresh…", "info", RELOAD_DELAY_MS + 2000)
// Remember that we're reloading for this build timestamp
try {
sessionStorage.setItem(STORAGE_KEY, serverBuildTime)
} catch (_) {
// ignore
}
// Auto-reload after short delay so toast is visible
setTimeout(() => {
window.location.reload()
}, RELOAD_DELAY_MS)
}

View File

@@ -12,9 +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:legacy": "node scripts/esbuild-wrapper.js build esbuild.config.legacy.js && babel _temp/index.js -o _temp/index.babeled.js && esbuild _temp/index.babeled.js --outfile=frontend/dist/index.es5.js --target=es5 --bundle --minify --sourcemap",
"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:test": "node scripts/esbuild-wrapper.js build esbuild.config.test.js && babel --config-file ./babel.config.test.json _temp/hook.test.js -o _temp/hook.test.babeled.js && esbuild _temp/hook.test.babeled.js --outfile=api/hooks/lib/hook.test.js --target=es5 --bundle --sourcemap --platform=node",
"test": "playwright test",
"test:e2e": "playwright test tests/e2e",
"test:api": "playwright test tests/api",
@@ -53,7 +51,6 @@
"dependencies": {
"@sentry/cli": "^3.2.0",
"@sentry/svelte": "^10.38.0",
"core-js": "3.48.0",
"cryptcha": "ssh://git@gitbase.de:2222/cms/cryptcha.git",
"svelte-i18n": "^4.0.1"
},

View File

@@ -17,6 +17,9 @@ let buildResults
let ctx
async function build(catchError) {
if (config.writeBuildInfo) {
config.writeBuildInfo()
}
if (!ctx) ctx = await esbuild.context(config.options)
log((buildResults ? "re" : "") + "building...")
const timerStart = Date.now()
@@ -66,6 +69,9 @@ switch (process.argv?.length > 2 ? process.argv[2] : "build") {
})
break
default:
if (config.writeBuildInfo) {
config.writeBuildInfo()
}
esbuild.build(config.options).then(function (buildResults) {
if (config.options.metafile) {
fs.writeFileSync(