Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add service worker #735

Merged
merged 6 commits into from
Apr 15, 2024
Merged
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
4 changes: 4 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export default [
languageOptions: { globals: { ...globals.browser } },
rules: { "wc/no-self-class": "warn" },
},
{
files: ["**/frontend/lib/serviceworker.js"],
languageOptions: { globals: { ...globals.serviceworker } },
},
{
files: ["**/test/**/*.js"],
rules: { "unicorn/no-process-exit": 0 },
Expand Down
1 change: 1 addition & 0 deletions helpers/frontend/views/frontend-components.njk
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@
<li>{{- icon("location") -}} location</li>
<li>{{- icon("next") -}} next</li>
<li>{{- icon("note") -}} note</li>
<li>{{- icon("offline") -}} offline</li>
<li>{{- icon("photo") -}} photo</li>
<li>{{- icon("previous") -}} previous</li>
<li>{{- icon("public") -}} public</li>
Expand Down
2 changes: 1 addition & 1 deletion jsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"resolveJsonModule": true,
"target": "es2022"
},
"exclude": ["node_modules"],
"exclude": ["node_modules", "packages/frontend/lib/serviceworker.js"],
"typeRoots": ["types", "./node_modules/@types"]
}
7 changes: 7 additions & 0 deletions packages/frontend/assets/offline.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions packages/frontend/layouts/default.njk
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,19 @@
}
}) if not minimalui }}
{% endblock %}
<script type="module">
if (navigator.serviceWorker) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/serviceworker.js", {
scope: '/'
});
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
command: "trimCaches"
});
}
});
}
</script>
</body>
</html>
2 changes: 2 additions & 0 deletions packages/frontend/lib/globals/icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const icon = (name, title) => {
location: "M44 3 30 8 18 4 4 9v36l14-5 12 4 14-5V3ZM29 40l-11-4V8l11 4v28Z",
next: "M28 7.5 40.5 20c3 3 3 5 0 8L28 40.5l-3-3L35.5 27l1.5-1H6v-4h31l-1.5-1L25 10.5l3-3Z",
note: "M12 8h24c2 0 4 2 4 4v24c0 2-2 4-4 4H12c-2 0-4-2-4-4V12c0-2 2-4 4-4zm24-4H12c-4 0-8 4-8 8v24c0 5 4 8 8 8h24c5 0 8-3 8-8V12c0-4-3-8-8-8zM12 14h24v4H12v-4zm0 8h24v4H12v-4zm0 8h18v4H12v-4z",
offline:
"M24 7a32 32 0 1 1-15.8 4.2l3.7 3.7a24.7 24.7 0 0 0-8.4 6.6L0 18v8.6a27 27 0 1 0 48 0V18l-3.5 3.5a27 27 0 0 0-25.1-9.1l-4.2-4.2A32 32 0 0 1 24 7Zm0 51a17 17 0 0 0 15.5-10h5.4A22 22 0 0 1 3 48h5.4A17 17 0 0 0 24 58Zm0-26a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm16-6-3.5 3.5-1.2-1.2-9.2-9.2a22 22 0 0 1 14 6.9Zm-22.9-6 4.1 4.1a17 17 0 0 0-9.7 5.3L8 26a22 22 0 0 1 9-6ZM2 5l3-3 41 41-3 3L2 5Z",
photo:
"M36 4H12c-4 0-8 4-8 8v24c0 5 4 8 8 8h24c5 0 8-3 8-8V12c0-4-3-8-8-8zM12 8h24c2 0 4 2 4 4v24c0 2-2 4-4 4H12c-2 0-4-2-4-4V12c0-2 2-4 4-4zm24 24l-9-14-7 11-3-5-5 8h24z",
previous:
Expand Down
210 changes: 210 additions & 0 deletions packages/frontend/lib/serviceworker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
const assetCacheName = "assets-APP_VERSION";
const pagesCacheName = "pages";
const imageCacheName = "images";
const maxPages = 50; // Maximum number of pages to cache
const maxImages = 100; // Maximum number of images to cache
const timeout = 5000; // Number of milliseconds before timing out
const cacheList = new Set([assetCacheName, pagesCacheName, imageCacheName]);
const placeholderImage = `<svg xmlns="http://www.w3.org/2000/svg"><defs><path id="icon" fill="#AAA" d="M24 32a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm-6.9-11.9 4.1 4.1a17 17 0 0 0-9.7 5.3L8 26a22 22 0 0 1 9-6Zm22.5 5.4L36 29l-.8-.8L26 19a22 22 0 0 1 13.5 6.4ZM8.2 11.2l3.7 3.7a24.7 24.7 0 0 0-8.4 6.6l-3.6-3.6c2.4-2.7 5.2-5 8.3-6.7ZM24 7a32 32 0 0 1 23.4 10.2l-3.5 3.6a27 27 0 0 0-24.5-8.4l-4.2-4.2A32 32 0 0 1 24 7ZM2 5l3-3 41 41-3 3L2 5Z" opacity=".7"/>
</defs><rect fill="#000" width="100%" height="100%" opacity="0.075"/><use href="#icon" x="50%" y="50%" transform="translate(-24 -24)"/></svg>`;

/**
* Update asset cache
* @returns {Promise<Cache>} - Updated asset cache
*/
async function updateAssetCache() {
try {
const assetCache = await caches.open(assetCacheName);

// These items won’t block the installation of the service worker
assetCache.addAll(["/app.webmanifest"]);

// These items must be cached for service worker to complete installation
await assetCache.addAll(["APP_CSS_PATH", "APP_JS_PATH", "/offline"]);

return assetCache;
} catch (error) {
console.error("Error updating asset cache", error);
}
}

/**
* Cache the page(s) that initiate the service worker
* @returns {Promise<Cache>} - Updated page cache
*/
async function cacheClients() {
const pages = [];
try {
const allClients = await clients.matchAll({ includeUncontrolled: true });

for (const client of allClients) {
pages.push(client.url);
}

const pagesCache = await caches.open(pagesCacheName);
await pagesCache.addAll(pages);

return pagesCache;
} catch (error) {
console.error("Error updating client cache", error);
}
}

/**
* Remove caches whose name is no longer valid
*/
async function clearOldCaches() {
try {
const keys = await caches.keys();

await Promise.all(
keys
.filter((key) => !cacheList.has(key))
.map((key) => caches.delete(key)),
);
} catch (error) {
console.error("Error clearing old caches", error);
}
}

/**
* Trim cache
* @param {string} cacheName - Name of cache
* @param {number} maxItems - Maximum number of items to keep in cache
*/
async function trimCache(cacheName, maxItems) {
try {
const cache = await caches.open(cacheName);
const keys = await cache.keys();

if (keys.length > maxItems) {
await cache.delete(keys[0]);
await trimCache(cacheName, maxItems);
}
} catch (error) {
console.error(`Error trimming ${cacheName} cache`, error);
}
}

self.addEventListener("install", async (event) => {
event.waitUntil(
(async () => {
await updateAssetCache();
await cacheClients();
self.skipWaiting();
})(),
);
});

self.addEventListener("activate", async (event) => {
event.waitUntil(
(async () => {
await clearOldCaches();
await clients.claim();
})(),
);
});

if (registration.navigationPreload) {
self.addEventListener("activate", (event) => {
event.waitUntil(registration.navigationPreload.enable());
});
}

self.addEventListener("message", (event) => {
if (event.data.command == "trimCaches") {
trimCache(pagesCacheName, maxPages);
trimCache(imageCacheName, maxImages);
}
});

self.addEventListener("fetch", (event) => {
const request = event.request;

// Ignore non-GET requests
if (request.method !== "GET") {
return;
}

const retrieveFromCache = caches.match(request);

// For HTML requests, try network, fall back to cache, else show offline page
if (
request.mode === "navigate" ||
request.headers.get("Accept").includes("text/html")
) {
event.respondWith(
(async () => {
// CHECK CACHE
const timer = setTimeout(async () => {
const responseFromCache = await retrieveFromCache;
if (responseFromCache) {
return responseFromCache;
}
}, timeout);

try {
const preloadResponse = await Promise.resolve(event.preloadResponse);
const responseFromPreloadOrFetch =
preloadResponse || (await fetch(request));

// NETWORK
// Save a copy of page to pages cache
clearTimeout(timer);
const copy = responseFromPreloadOrFetch.clone();
const pagesCache = await caches.open(pagesCacheName);
await pagesCache.put(request, copy);

return responseFromPreloadOrFetch;
} catch (error) {
console.error(error, request);

// CACHE or OFFLINE PAGE
clearTimeout(timer);
const responseFromCache = await retrieveFromCache;
return responseFromCache || caches.match("/offline");
}
})(),
);

return;
}

// For non-HTML requests, look in cache first, fall back to network
event.respondWith(
(async () => {
try {
const responseFromCache = await retrieveFromCache;

if (responseFromCache) {
// CACHE
return responseFromCache;
} else {
const responseFromFetch = await fetch(request);

// NETWORK
// If request is for an image, save a copy to images cache
if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
const copy = responseFromFetch.clone();
const imagesCache = await caches.open(imageCacheName);
await imagesCache.put(request, copy);
}

return responseFromFetch;
}
} catch (error) {
console.error(error);

// OFFLINE IMAGE
if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
return new Response(placeholderImage, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "no-store",
},
});
}
}
})(),
);
});
6 changes: 5 additions & 1 deletion packages/indiekit/lib/controllers/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const get = async (request, response) => {
$schema: "https://json.schemastore.org/web-manifest-combined.json",
lang: application.locale,
name: application.name,
scope: "/",
icons: [
{
src: "assets/app-icon-192-any.png",
Expand All @@ -29,9 +30,12 @@ export const get = async (request, response) => {
purpose: "maskable",
},
],
shortcuts: getShortcuts(application, response),
display: "standalone",
start_url: "/?homescreen",
id: "/?homescreen",
theme_color: getThemeColor(application.themeColor),
background_color: getBackgroundColor(application.themeColor),
shortcuts: getShortcuts(application, response),
...(application.shareEndpoint && {
share_target: {
action: application.shareEndpoint,
Expand Down
14 changes: 14 additions & 0 deletions packages/indiekit/lib/controllers/offline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getServiceWorker } from "../utils.js";

export const offline = (request, response) => {
response.render("offline", {
title: response.locals.__("offline.title"),
});
};

export const serviceworker = async (request, response) => {
const { application } = request.app.locals;
const serviceworker = await getServiceWorker(application);

return response.type("text/javascript").send(serviceworker).end();
};
5 changes: 5 additions & 0 deletions packages/indiekit/lib/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as assetsController from "./controllers/assets.js";
import * as feedController from "./controllers/feed.js";
import * as homepageController from "./controllers/homepage.js";
import * as manifestController from "./controllers/manifest.js";
import * as offlineController from "./controllers/offline.js";
import * as pluginController from "./controllers/plugin.js";
import * as sessionController from "./controllers/session.js";
import * as statusController from "./controllers/status.js";
Expand Down Expand Up @@ -51,6 +52,10 @@ export const routes = (indiekitConfig) => {
assetsController.getShortcutIcon,
);

// Service worker
router.get("/serviceworker.js", offlineController.serviceworker);
router.get("/offline", offlineController.offline);

// Plug-in assets
for (const plugin of application.installedPlugins) {
if (plugin.filePath) {
Expand Down
20 changes: 20 additions & 0 deletions packages/indiekit/lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { Buffer } from "node:buffer";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";

Expand Down Expand Up @@ -40,6 +41,25 @@ export const decrypt = (hash, iv) => {
return decrypted.toString();
};

/**
* Get serviceworker.js and update asset versions
* @param {object} application - Application locals
* @returns {Promise<string>} - serviceworker.js file
*/
export const getServiceWorker = async (application) => {
try {
const filePath = require.resolve("@indiekit/frontend/lib/serviceworker.js");
let serviceworker = await readFile(filePath, { encoding: "utf8" });
serviceworker = serviceworker
.replace("APP_VERSION", application.version)
.replace("APP_CSS_PATH", application.cssPath)
.replace("APP_JS_PATH", application.jsPath);
return serviceworker;
} catch (error) {
console.error(error.message);
}
};

/**
* Get fully resolved server URL
* @param {import("express").Request} request - Request
Expand Down
4 changes: 4 additions & 0 deletions packages/indiekit/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"homepage": {
"title": "Willkommen!"
},
"offline": {
"description": "Diese Seite kann nicht angezeigt werden, da Sie derzeit offline sind.",
"title": "Offline"
},
"plugin": {
"options": "Optionen"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/indiekit/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"homepage": {
"title": "Welcome!"
},
"offline": {
"title": "Offline",
"description": "This page cannot be displayed because you are currently offline."
},
"plugin": {
"options": "Options"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/indiekit/locales/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"homepage": {
"title": "¡Bienvenido!"
},
"offline": {
"description": "No se puede mostrar esta página porque estás desconectado actualmente.",
"title": "Sin conexión"
},
"plugin": {
"options": "Opciones"
},
Expand Down
Loading
Loading