✨ feat: implement build version check and update build info handling
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
api/hooks/lib/app.server*
|
||||
api/hooks/lib/buildInfo.js
|
||||
frontend/src/lib/buildInfo.ts
|
||||
node_modules
|
||||
media
|
||||
tmp
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -50,8 +50,6 @@ 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,
|
||||
@@ -68,22 +66,6 @@ if (typeof history !== "undefined") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
} // else ssr -> no history handling
|
||||
|
||||
typeof window !== "undefined" &&
|
||||
|
||||
54
frontend/src/lib/versionCheck.ts
Normal file
54
frontend/src/lib/versionCheck.ts
Normal 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)
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user