diff --git a/docker/Dockerfile b/docker/Dockerfile index 5047ad3ae..e58cac2c5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -26,7 +26,7 @@ COPY . . RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete RUN npm cache clean --force && \ - NODE_OPTIONS="--max-old-space-size=3072" npm run build + TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build # Stage 3: Build backend FROM deps AS backend-builder diff --git a/package.json b/package.json index 46d62ac03..1b7331cba 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev:electron": "node scripts/dev.mjs electron", "dev:docker": "node scripts/dev.mjs docker", "dev:frontend": "vite", - "build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", + "build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs", "electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty", diff --git a/public/sw.js b/public/sw.js index 2ddb64980..c22a32b2b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,9 @@ -const CACHE_NAME = "termix-static-v2"; +// CACHE_NAME carries the package version so a new release always gets a fresh +// cache: the activate handler below deletes every cache whose name differs +// from this one, which is what evicts a previous build's entries. Both +// placeholders on these lines are stamped by scripts/stamp-sw-build-info.cjs +// during `npm run build` - vite copies public/ verbatim and never transforms it. +const CACHE_NAME = "termix-static-" + "__TERMIX_CACHE_VERSION__"; const BASE_PATH = "__TERMIX_SW_BASE_PATH__"; const STATIC_ASSETS = [ `${BASE_PATH}/favicon.ico`, diff --git a/scripts/stamp-sw-build-info.cjs b/scripts/stamp-sw-build-info.cjs new file mode 100644 index 000000000..c46372c93 --- /dev/null +++ b/scripts/stamp-sw-build-info.cjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** Stamp the cache version and static-build base path. + * Docker keeps the base-path placeholder for entrypoint.sh to fill at startup. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); +const SW_PATH = path.join(ROOT, "dist", "sw.js"); + +const VERSION_PLACEHOLDER = "__TERMIX_CACHE_VERSION__"; +const BASE_PATH_PLACEHOLDER = "__TERMIX_SW_BASE_PATH__"; + +const pkg = JSON.parse( + fs.readFileSync(path.join(ROOT, "package.json"), "utf8"), +); +const cacheVersion = String(pkg.version || "0.0.0").replace(/\./g, "-"); + +const rawBasePath = process.env.VITE_BASE_PATH || "./"; +// "./" is what vite emits for a root deployment; cache URLs stay root-relative. +const basePath = + rawBasePath === "./" + ? "" + : `/${rawBasePath.replace(/^\/+/, "").replace(/\/+$/, "")}`; + +if (!fs.existsSync(SW_PATH)) { + console.error(`[stamp-sw] dist/sw.js not found - run vite build first`); + process.exit(1); +} + +let sw = fs.readFileSync(SW_PATH, "utf8"); +const rel = path.relative(ROOT, SW_PATH); +const replacements = [ + [VERSION_PLACEHOLDER, cacheVersion], + ...(process.env.TERMIX_RUNTIME_BASE_PATH === "true" + ? [] + : [[BASE_PATH_PLACEHOLDER, basePath]]), +]; + +let changed = false; +for (const [placeholder, value] of replacements) { + if (sw.includes(placeholder)) { + sw = sw.split(placeholder).join(value); + changed = true; + console.log( + `[stamp-sw] ${rel}: ${placeholder} -> ${JSON.stringify(value)}`, + ); + } +} + +if (changed) { + fs.writeFileSync(SW_PATH, sw); +} diff --git a/scripts/stamp-sw-build-info.test.ts b/scripts/stamp-sw-build-info.test.ts new file mode 100644 index 000000000..b7844be82 --- /dev/null +++ b/scripts/stamp-sw-build-info.test.ts @@ -0,0 +1,59 @@ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + writeFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; + +it.each([ + [false, "", ""], + [false, "/termix/", "/termix"], + [true, "", "__TERMIX_SW_BASE_PATH__"], +])( + "stamps service worker paths (runtime=%s, base=%s)", + (runtime, base, expected) => { + const dir = mkdtempSync(path.join(tmpdir(), "termix-sw-")); + try { + mkdirSync(path.join(dir, "scripts")); + mkdirSync(path.join(dir, "dist")); + writeFileSync( + path.join(dir, "scripts/stamp-sw-build-info.cjs"), + readFileSync("scripts/stamp-sw-build-info.cjs"), + ); + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ version: "26.10.2" }), + ); + writeFileSync(path.join(dir, "dist/sw.js"), readFileSync("public/sw.js")); + execFileSync( + process.execPath, + [path.join(dir, "scripts/stamp-sw-build-info.cjs")], + { + env: { + ...process.env, + VITE_BASE_PATH: base, + TERMIX_RUNTIME_BASE_PATH: String(runtime), + }, + }, + ); + const sw = readFileSync(path.join(dir, "dist/sw.js"), "utf8"); + expect(sw).toContain('"26-10-2"'); + expect(sw).toContain(`const BASE_PATH = "${expected}";`); + if (runtime) { + expect(sw.replaceAll("__TERMIX_SW_BASE_PATH__", "/termix")).toContain( + 'const BASE_PATH = "/termix";', + ); + expect(readFileSync("docker/Dockerfile", "utf8")).toContain( + "TERMIX_RUNTIME_BASE_PATH=true", + ); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +);