From e13e6962538360323b241acd593117cf51441620 Mon Sep 17 00:00:00 2001 From: Sebastian Frank Date: Wed, 25 Feb 2026 15:53:00 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20implement=20build=20version?= =?UTF-8?q?=20check=20and=20update=20build=20info=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + api/hooks/config-client.js | 5 +- babel.config.json | 20 ------- esbuild.config.js | 97 +++++++++++++++++++++++++++++++- esbuild.config.legacy.js | 8 --- esbuild.config.server.js | 9 +++ frontend/src/lib/store.ts | 48 +++++----------- frontend/src/lib/versionCheck.ts | 54 ++++++++++++++++++ package.json | 3 - scripts/esbuild-wrapper.js | 6 ++ 10 files changed, 186 insertions(+), 66 deletions(-) delete mode 100644 babel.config.json delete mode 100644 esbuild.config.legacy.js create mode 100644 frontend/src/lib/versionCheck.ts diff --git a/.gitignore b/.gitignore index cebcffd..925372f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ api/hooks/lib/app.server* +api/hooks/lib/buildInfo.js +frontend/src/lib/buildInfo.ts node_modules media tmp diff --git a/api/hooks/config-client.js b/api/hooks/config-client.js index 89d4218..6bf4857 100644 --- a/api/hooks/config-client.js +++ b/api/hooks/config-client.js @@ -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, diff --git a/babel.config.json b/babel.config.json deleted file mode 100644 index bfc729c..0000000 --- a/babel.config.json +++ /dev/null @@ -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": [] -} diff --git a/esbuild.config.js b/esbuild.config.js index 8879992..7ec063f 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -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, }), ], diff --git a/esbuild.config.legacy.js b/esbuild.config.legacy.js deleted file mode 100644 index fd05b9f..0000000 --- a/esbuild.config.legacy.js +++ /dev/null @@ -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 diff --git a/esbuild.config.server.js b/esbuild.config.server.js index 9e6e67e..9ec3036 100644 --- a/esbuild.config.server.js +++ b/esbuild.config.server.js @@ -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" diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index 8c7171a..739283c 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -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" && diff --git a/frontend/src/lib/versionCheck.ts b/frontend/src/lib/versionCheck.ts new file mode 100644 index 0000000..8e13241 --- /dev/null +++ b/frontend/src/lib/versionCheck.ts @@ -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) +} diff --git a/package.json b/package.json index a0ffb9e..99b5c8f 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/esbuild-wrapper.js b/scripts/esbuild-wrapper.js index 2b8e9ab..44ce0aa 100644 --- a/scripts/esbuild-wrapper.js +++ b/scripts/esbuild-wrapper.js @@ -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(