✨ 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/app.server*
|
||||||
|
api/hooks/lib/buildInfo.js
|
||||||
|
frontend/src/lib/buildInfo.ts
|
||||||
node_modules
|
node_modules
|
||||||
media
|
media
|
||||||
tmp
|
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 originURL = "https://__PROJECT__.code.testversion.online"
|
||||||
const apiClientBaseURL = "/api/"
|
const apiClientBaseURL = "/api/"
|
||||||
@@ -8,10 +9,12 @@ const cryptchaSiteId = "6628f06a0938460001505119"
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (release && typeof context !== "undefined") {
|
if (release && typeof context !== "undefined") {
|
||||||
context.response.header("X-Release", release)
|
context.response.header("X-Release", release)
|
||||||
|
context.response.header("X-Build-Time", buildTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
release,
|
release,
|
||||||
|
buildTime,
|
||||||
apiClientBaseURL,
|
apiClientBaseURL,
|
||||||
cryptchaSiteId,
|
cryptchaSiteId,
|
||||||
originURL,
|
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 fs = require("fs")
|
||||||
|
const { execSync } = require("child_process")
|
||||||
const postcssPlugin = require("esbuild-postcss")
|
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 = {
|
const resolvePlugin = {
|
||||||
name: "resolvePlugin",
|
name: "resolvePlugin",
|
||||||
setup(build) {
|
setup(build) {
|
||||||
@@ -63,7 +93,7 @@ const options = {
|
|||||||
".ttf": "file",
|
".ttf": "file",
|
||||||
},
|
},
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
target: ["es2020", "chrome61", "firefox60", "safari11", "edge18"],
|
target: ["es2022", "chrome100", "firefox100", "safari15", "edge100"],
|
||||||
}
|
}
|
||||||
|
|
||||||
const bsMiddleware = []
|
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) {
|
if (process.env.SSR) {
|
||||||
bsMiddleware.push(
|
bsMiddleware.push(
|
||||||
createProxyMiddleware({
|
createProxyMiddleware({
|
||||||
@@ -103,6 +183,7 @@ if (process.argv[2] == "start") {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
sveltePlugin: sveltePlugin,
|
sveltePlugin: sveltePlugin,
|
||||||
resolvePlugin: resolvePlugin,
|
resolvePlugin: resolvePlugin,
|
||||||
|
writeBuildInfo: writeBuildInfo,
|
||||||
options: options,
|
options: options,
|
||||||
distDir,
|
distDir,
|
||||||
watch: {
|
watch: {
|
||||||
@@ -121,6 +202,20 @@ module.exports = {
|
|||||||
...bsMiddleware,
|
...bsMiddleware,
|
||||||
require("connect-history-api-fallback")({
|
require("connect-history-api-fallback")({
|
||||||
index: "/spa.html",
|
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,
|
// 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 config = require("./esbuild.config.js")
|
||||||
const svelteConfig = require("./svelte.config")
|
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.sourcemap = "inline"
|
||||||
config.options.minify = false
|
config.options.minify = false
|
||||||
config.options.platform = "node"
|
config.options.platform = "node"
|
||||||
|
|||||||
@@ -50,40 +50,22 @@ const publishLocation = (_p?: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof history !== "undefined") {
|
if (typeof history !== "undefined") {
|
||||||
if (typeof Proxy !== "undefined") {
|
const historyApply = (
|
||||||
// modern browser
|
target: (this: any, ...args: readonly any[]) => unknown,
|
||||||
const historyApply = (
|
thisArg: any,
|
||||||
target: (this: any, ...args: readonly any[]) => unknown,
|
argumentsList: string | readonly any[]
|
||||||
thisArg: any,
|
) => {
|
||||||
argumentsList: string | readonly any[]
|
publishLocation(argumentsList && argumentsList.length >= 2 && argumentsList[2])
|
||||||
) => {
|
Reflect.apply(target, thisArg, argumentsList)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
history.pushState = new Proxy(history.pushState, {
|
||||||
|
apply: historyApply,
|
||||||
|
})
|
||||||
|
|
||||||
|
history.replaceState = new Proxy(history.replaceState, {
|
||||||
|
apply: historyApply,
|
||||||
|
})
|
||||||
} // else ssr -> no history handling
|
} // else ssr -> no history handling
|
||||||
|
|
||||||
typeof window !== "undefined" &&
|
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",
|
"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: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: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": "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",
|
||||||
@@ -53,7 +51,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/cli": "^3.2.0",
|
"@sentry/cli": "^3.2.0",
|
||||||
"@sentry/svelte": "^10.38.0",
|
"@sentry/svelte": "^10.38.0",
|
||||||
"core-js": "3.48.0",
|
|
||||||
"cryptcha": "ssh://git@gitbase.de:2222/cms/cryptcha.git",
|
"cryptcha": "ssh://git@gitbase.de:2222/cms/cryptcha.git",
|
||||||
"svelte-i18n": "^4.0.1"
|
"svelte-i18n": "^4.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ let buildResults
|
|||||||
let ctx
|
let ctx
|
||||||
|
|
||||||
async function build(catchError) {
|
async function build(catchError) {
|
||||||
|
if (config.writeBuildInfo) {
|
||||||
|
config.writeBuildInfo()
|
||||||
|
}
|
||||||
if (!ctx) ctx = await esbuild.context(config.options)
|
if (!ctx) ctx = await esbuild.context(config.options)
|
||||||
log((buildResults ? "re" : "") + "building...")
|
log((buildResults ? "re" : "") + "building...")
|
||||||
const timerStart = Date.now()
|
const timerStart = Date.now()
|
||||||
@@ -66,6 +69,9 @@ switch (process.argv?.length > 2 ? process.argv[2] : "build") {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
|
if (config.writeBuildInfo) {
|
||||||
|
config.writeBuildInfo()
|
||||||
|
}
|
||||||
esbuild.build(config.options).then(function (buildResults) {
|
esbuild.build(config.options).then(function (buildResults) {
|
||||||
if (config.options.metafile) {
|
if (config.options.metafile) {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
Reference in New Issue
Block a user