diff --git a/.docker/templates/default.conf.template b/.docker/templates/default.conf.template index 8f789dca5..c59addff7 100644 --- a/.docker/templates/default.conf.template +++ b/.docker/templates/default.conf.template @@ -11,6 +11,12 @@ server { real_ip_recursive on; real_ip_header X-Forwarded-For; + location = /client/sw.js { + add_header Cache-Control "no-cache"; + add_header Service-Worker-Allowed "/"; + try_files $uri =404; + } + location / { # try to serve file directly, fallback to index.php try_files $uri /index.php$is_args$args; diff --git a/.gitignore b/.gitignore index ecee3e2fe..88baec793 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ # Ignore release json file. /public/release.json +# Ignore generated service worker (built from scripts/sw-template.js). +/public/client/sw.js + ###> symfony/framework-bundle ### /.env.local /.env.local.php diff --git a/assets/client/index.jsx b/assets/client/index.jsx index dc7e35ef9..4e52c2647 100644 --- a/assets/client/index.jsx +++ b/assets/client/index.jsx @@ -9,3 +9,24 @@ const container = document.getElementById("root"); const root = createRoot(container); root.render(); + +if ( + "serviceWorker" in navigator && + import.meta.env.MODE === "production" +) { + const register = () => + navigator.serviceWorker + .register("/client/sw.js", { scope: "/" }) + .then((registration) => + console.log("Service worker registered:", registration.scope), + ) + .catch((error) => + console.error("Service worker registration failed:", error), + ); + + if (document.readyState === "complete") { + register(); + } else { + window.addEventListener("load", register); + } +} diff --git a/infrastructure/itkdev/nginx/etc/confd/templates/default.conf.tmpl b/infrastructure/itkdev/nginx/etc/confd/templates/default.conf.tmpl index b6f32099b..3829762f9 100644 --- a/infrastructure/itkdev/nginx/etc/confd/templates/default.conf.tmpl +++ b/infrastructure/itkdev/nginx/etc/confd/templates/default.conf.tmpl @@ -4,6 +4,12 @@ server { server_name localhost; root /var/www/html/public; + location = /client/sw.js { + add_header Cache-Control "no-cache"; + add_header Service-Worker-Allowed "/"; + try_files $uri =404; + } + location / { add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"; diff --git a/infrastructure/os2display/nginx/etc/confd/templates/default.conf.tmpl b/infrastructure/os2display/nginx/etc/confd/templates/default.conf.tmpl index b6f32099b..3829762f9 100644 --- a/infrastructure/os2display/nginx/etc/confd/templates/default.conf.tmpl +++ b/infrastructure/os2display/nginx/etc/confd/templates/default.conf.tmpl @@ -4,6 +4,12 @@ server { server_name localhost; root /var/www/html/public; + location = /client/sw.js { + add_header Cache-Control "no-cache"; + add_header Service-Worker-Allowed "/"; + try_files $uri =404; + } + location / { add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"; diff --git a/package.json b/package.json index c7cc22fa1..ec7712c94 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/generate-sw.js", "test:unit": "vitest run", "test:unit:watch": "vitest" }, diff --git a/scripts/generate-sw.js b/scripts/generate-sw.js new file mode 100644 index 000000000..7e82769e2 --- /dev/null +++ b/scripts/generate-sw.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// Generate public/sw.js from scripts/sw-template.js by injecting the +// list of client assets to precache from the Vite manifest. + +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(__dirname, ".."); + +const VITE_BASE = "/build"; +const CLIENT_ENTRY = "assets/client/index.jsx"; +const MANIFEST_PATH = resolve(projectRoot, "public/build/.vite/manifest.json"); +const TEMPLATE_PATH = resolve(__dirname, "sw-template.js"); +const OUTPUT_PATH = resolve(projectRoot, "public/client/sw.js"); + +const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")); + +const clientEntry = manifest[CLIENT_ENTRY]; +if (!clientEntry) { + throw new Error(`Client entry "${CLIENT_ENTRY}" not found in Vite manifest`); +} + +const collectAssets = (entryKey, collected = new Set()) => { + const entry = manifest[entryKey]; + if (!entry) return collected; + + if (entry.file) collected.add(entry.file); + for (const css of entry.css ?? []) collected.add(css); + for (const asset of entry.assets ?? []) collected.add(asset); + + for (const importKey of entry.imports ?? []) { + collectAssets(importKey, collected); + } + + return collected; +}; + +const assets = Array.from(collectAssets(CLIENT_ENTRY)) + .map((file) => `${VITE_BASE}/${file}`) + .sort(); + +const buildHash = createHash("sha256") + .update(assets.join("\n")) + .digest("hex") + .slice(0, 16); + +const template = readFileSync(TEMPLATE_PATH, "utf-8"); + +const output = template + .replace(/"__BUILD_HASH__"/g, JSON.stringify(buildHash)) + .replace(/__PRECACHE_MANIFEST__/g, JSON.stringify(assets, null, 2)); + +writeFileSync(OUTPUT_PATH, output); + +console.log( + `Generated ${OUTPUT_PATH} (cache: os2display-client-${buildHash}, ${assets.length} assets)`, +); diff --git a/scripts/sw-template.js b/scripts/sw-template.js new file mode 100644 index 000000000..09a0de359 --- /dev/null +++ b/scripts/sw-template.js @@ -0,0 +1,91 @@ +/* eslint-disable no-restricted-globals */ +// Service worker for the OS2Display client app. +// This file is a template — placeholders are replaced at build time by scripts/generate-sw.js. + +const CACHE_VERSION = "__BUILD_HASH__"; +const CACHE_NAME = `os2display-client-${CACHE_VERSION}`; +const PRECACHE_ASSETS = __PRECACHE_MANIFEST__; + +const CLIENT_NAVIGATION_PATTERN = /^\/client(\/|$|\?)/; +const BUILD_ASSETS_PATTERN = /^\/build\/assets\//; +const RELEASE_JSON_PATTERN = /^\/release\.json$/; +const CLIENT_HTML_CACHE_KEY = "/client"; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(PRECACHE_ASSETS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter( + (key) => + key.startsWith("os2display-client-") && key !== CACHE_NAME, + ) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +const networkFirst = async (request, cacheKey) => { + const cache = await caches.open(CACHE_NAME); + try { + const response = await fetch(request); + if (response && response.ok) { + cache.put(cacheKey || request, response.clone()); + } + return response; + } catch (error) { + const cached = await cache.match(cacheKey || request); + if (cached) return cached; + throw error; + } +}; + +const cacheFirst = async (request) => { + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response && response.ok) { + cache.put(request, response.clone()); + } + return response; +}; + +self.addEventListener("fetch", (event) => { + const { request } = event; + + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if ( + request.mode === "navigate" && + CLIENT_NAVIGATION_PATTERN.test(url.pathname) + ) { + event.respondWith(networkFirst(request, CLIENT_HTML_CACHE_KEY)); + return; + } + + if (BUILD_ASSETS_PATTERN.test(url.pathname)) { + event.respondWith(cacheFirst(request)); + return; + } + + if (RELEASE_JSON_PATTERN.test(url.pathname)) { + event.respondWith(networkFirst(request)); + } +});