Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .docker/templates/default.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions assets/client/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,24 @@ const container = document.getElementById("root");
const root = createRoot(container);

root.render(<App preview={preview} previewId={previewId} />);

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
60 changes: 60 additions & 0 deletions scripts/generate-sw.js
Original file line number Diff line number Diff line change
@@ -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)`,
);
91 changes: 91 additions & 0 deletions scripts/sw-template.js
Original file line number Diff line number Diff line change
@@ -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));
}
});
Loading