diff --git a/app/entry.worker.ts b/app/entry.worker.ts index 2e87d83..496238f 100644 --- a/app/entry.worker.ts +++ b/app/entry.worker.ts @@ -7,7 +7,7 @@ import handleNotificationClick from "./service-worker/notification-click"; import handleFetch from "./service-worker/fetch"; import handleMessage from "./service-worker/message"; -declare let self: ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope; self.addEventListener("install", (event) => { event.waitUntil(handleInstall(event).then(() => self.skipWaiting())); diff --git a/app/features/core/components/service-worker-update-notifier.tsx b/app/features/core/components/service-worker-update-notifier.tsx index b873807..f23a4b3 100644 --- a/app/features/core/components/service-worker-update-notifier.tsx +++ b/app/features/core/components/service-worker-update-notifier.tsx @@ -53,7 +53,6 @@ export default function ServiceWorkerUpdateNotifier() { aria-label="An updated version of the app is available. Reload to get the latest version." /> - ; ); } diff --git a/app/service-worker/activate.ts b/app/service-worker/activate.ts index b228ac0..5de0751 100644 --- a/app/service-worker/activate.ts +++ b/app/service-worker/activate.ts @@ -1,6 +1,6 @@ import { deleteCaches } from "./cache-utils"; -declare let self: ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope; export default async function handleActivate(event: ExtendableEvent) { console.debug("Service worker activated"); diff --git a/app/service-worker/cache-utils.ts b/app/service-worker/cache-utils.ts index 7afa412..bcdd9a4 100644 --- a/app/service-worker/cache-utils.ts +++ b/app/service-worker/cache-utils.ts @@ -1,8 +1,8 @@ import { json } from "@remix-run/server-runtime"; -export const ASSET_CACHE = "asset-cache"; -export const DATA_CACHE = "data-cache"; -export const DOCUMENT_CACHE = "document-cache"; +declare const ASSET_CACHE: string; +declare const DATA_CACHE: string; +declare const DOCUMENT_CACHE: string; export function isAssetRequest(request: Request) { return ["font", "image", "script", "style"].includes(request.destination); @@ -17,7 +17,7 @@ export function isDocumentGetRequest(request: Request) { return request.method.toLowerCase() === "get" && request.mode === "navigate"; } -export function cacheAsset(event: FetchEvent): Promise { +export function fetchAsset(event: FetchEvent): Promise { // stale-while-revalidate const url = new URL(event.request.url); return caches @@ -53,7 +53,7 @@ export function cacheAsset(event: FetchEvent): Promise { // stores the timestamp for when each URL's cached response has been revalidated const lastTimeRevalidated: Record = {}; -export function cacheLoaderData(event: FetchEvent): Promise { +export function fetchLoaderData(event: FetchEvent): Promise { const url = new URL(event.request.url); const path = url.pathname + url.search; @@ -145,7 +145,7 @@ async function areResponsesEqual(a: Response, b: Response): Promise { return true; } -export function cacheDocument(event: FetchEvent): Promise { +export function fetchDocument(event: FetchEvent): Promise { // network-first const url = new URL(event.request.url); console.debug("Serving document from network", url.pathname); @@ -170,6 +170,7 @@ export function cacheDocument(event: FetchEvent): Promise { export async function deleteCaches() { const allCaches = await caches.keys(); - await Promise.all(allCaches.map((cacheName) => caches.delete(cacheName))); + const cachesToDelete = allCaches.filter((cacheName) => cacheName !== ASSET_CACHE); + await Promise.all(cachesToDelete.map((cacheName) => caches.delete(cacheName))); console.debug("Caches deleted"); } diff --git a/app/service-worker/fetch.ts b/app/service-worker/fetch.ts index 7ed8cfd..bc81535 100644 --- a/app/service-worker/fetch.ts +++ b/app/service-worker/fetch.ts @@ -1,25 +1,25 @@ import { - cacheAsset, - cacheDocument, - cacheLoaderData, + fetchAsset, + fetchDocument, + fetchLoaderData, isAssetRequest, isDocumentGetRequest, isLoaderRequest, } from "./cache-utils"; -declare let self: ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope; export default async function handleFetch(event: FetchEvent) { if (isAssetRequest(event.request)) { - return cacheAsset(event); + return fetchAsset(event); } if (isLoaderRequest(event.request)) { - return cacheLoaderData(event); + return fetchLoaderData(event); } if (isDocumentGetRequest(event.request)) { - return cacheDocument(event); + return fetchDocument(event); } return fetch(event.request); diff --git a/app/service-worker/install.ts b/app/service-worker/install.ts index 3ed9c6f..33e6f52 100644 --- a/app/service-worker/install.ts +++ b/app/service-worker/install.ts @@ -1,4 +1,4 @@ -declare let self: ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope; export default async function handleInstall(event: ExtendableEvent) { console.debug("Service worker installed"); diff --git a/app/service-worker/message.ts b/app/service-worker/message.ts index 9eec92d..a734b85 100644 --- a/app/service-worker/message.ts +++ b/app/service-worker/message.ts @@ -1,8 +1,7 @@ import type { AssetsManifest } from "@remix-run/react/entry"; -import { ASSET_CACHE } from "./cache-utils"; - -declare let self: ServiceWorkerGlobalScope; +declare const ASSET_CACHE: string; +declare const self: ServiceWorkerGlobalScope; export default async function handleMessage(event: ExtendableMessageEvent) { if (event.data.type === "SYNC_REMIX_MANIFEST") { @@ -13,32 +12,31 @@ export default async function handleMessage(event: ExtendableMessageEvent) { async function handleSyncRemixManifest(event: ExtendableMessageEvent) { console.debug("Caching routes modules"); - await cacheStaticAssets(event.data.manifest); -} - -async function cacheStaticAssets(manifest: AssetsManifest) { - const cachePromises: Map> = new Map(); - const assetCache = await caches.open(ASSET_CACHE); + const manifest: AssetsManifest = event.data.manifest; const routes = [...Object.values(manifest.routes), manifest.entry]; - + const assetsToCache: string[] = []; for (const route of routes) { - if (!cachePromises.has(route.module)) { - cachePromises.set(route.module, cacheAsset(route.module)); - } + assetsToCache.push(route.module); if (route.imports) { - for (const assetUrl of route.imports) { - if (!cachePromises.has(assetUrl)) { - cachePromises.set(assetUrl, cacheAsset(assetUrl)); - } - } + assetsToCache.push(...route.imports); } } + await purgeStaticAssets(assetsToCache); + await cacheStaticAssets(assetsToCache); +} + +async function cacheStaticAssets(assetsToCache: string[]) { + const cachePromises: Map> = new Map(); + const assetCache = await caches.open(ASSET_CACHE); + + assetsToCache.forEach((assetUrl) => cachePromises.set(assetUrl, cacheAsset(assetUrl))); await Promise.all(cachePromises.values()); async function cacheAsset(assetUrl: string) { if (await assetCache.match(assetUrl)) { + // no need to update the asset, it has a unique hash in its name return; } @@ -48,3 +46,14 @@ async function cacheStaticAssets(manifest: AssetsManifest) { }); } } + +async function purgeStaticAssets(assetsToCache: string[]) { + const assetCache = await caches.open(ASSET_CACHE); + const cachedAssets = await assetCache.keys(); + const cachesToDelete = cachedAssets.filter((asset) => !assetsToCache.includes(new URL(asset.url).pathname)); + console.log( + "cachesToDelete", + cachesToDelete.map((c) => new URL(c.url).pathname), + ); + await Promise.all(cachesToDelete.map((asset) => assetCache.delete(asset))); +} diff --git a/app/service-worker/notification-click.ts b/app/service-worker/notification-click.ts index a448412..dd7c90a 100644 --- a/app/service-worker/notification-click.ts +++ b/app/service-worker/notification-click.ts @@ -1,6 +1,6 @@ import { removeBadge } from "~/utils/pwa.client"; -declare let self: ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope; // noinspection TypeScriptUnresolvedVariable export default async function handleNotificationClick(event: NotificationEvent) { diff --git a/app/service-worker/push.ts b/app/service-worker/push.ts index 8f8f25b..24d3c8f 100644 --- a/app/service-worker/push.ts +++ b/app/service-worker/push.ts @@ -1,7 +1,7 @@ import type { NotificationPayload } from "~/utils/web-push.server"; import { addBadge } from "~/utils/pwa.client"; -declare let self: ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope; const defaultOptions: NotificationOptions = { icon: "/icons/android-chrome-192x192.png", diff --git a/package-lock.json b/package-lock.json index c5b3f59..37818c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "bullmq": "1.85.1", "clsx": "1.1.1", "compression": "1.7.4", - "cross-env": "7.0.3", "express": "4.18.1", "ioredis": "5.0.6", "isbot": "3.5.0", @@ -75,6 +74,7 @@ "esbuild": "0.14.42", "esbuild-node-externals": "1.4.1", "eslint": "8.16.0", + "glob": "7.2.3", "happy-dom": "5.0.0", "husky": "7.0.4", "lint-staged": "13.0.0", @@ -7298,27 +7298,11 @@ "node": "*" } }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -16194,6 +16178,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } @@ -19450,6 +19435,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -19461,6 +19447,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } @@ -22389,6 +22376,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -28071,18 +28059,11 @@ } } }, - "cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "requires": { - "cross-spawn": "^7.0.1" - } - }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -34524,7 +34505,8 @@ "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true }, "path-parse": { "version": "1.0.7", @@ -37005,6 +36987,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "requires": { "shebang-regex": "^3.0.0" } @@ -37012,7 +36995,8 @@ "shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true }, "shell-quote": { "version": "1.7.3", @@ -39316,6 +39300,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "requires": { "isexe": "^2.0.0" } diff --git a/package.json b/package.json index 1d6c849..3bc3d61 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,19 @@ "private": true, "sideEffects": false, "scripts": { - "dev:build": "cross-env NODE_ENV=development dotenv npm run build:server -- --watch", - "dev:css": "cross-env NODE_ENV=development tailwindcss -i ./styles/tailwind.css -o ./app/styles/tailwind.css --watch", - "dev:remix": "cross-env NODE_ENV=development remix watch", - "dev:server": "cross-env NODE_ENV=development dotenv node ./server.js", - "dev:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --watch", - "dev:init": "cross-env NODE_ENV=development dotenv run-s build:remix build:server", + "dev:build": "NODE_ENV=development dotenv npm run build:server -- -- --watch", + "dev:css": "NODE_ENV=development tailwindcss -i ./styles/tailwind.css -o ./app/styles/tailwind.css --watch", + "dev:remix": "NODE_ENV=development remix watch", + "dev:server": "NODE_ENV=development dotenv node ./server.js", + "dev:worker": "NODE_ENV=development npm run build:worker -- --watch", + "dev:init": "NODE_ENV=development dotenv run-s build:remix build:server", "dev": "npm run dev:init && run-p dev:build dev:worker dev:css dev:remix dev:server", "build:server": "node ./scripts/build-server.js", "build:css": "tailwindcss -i ./styles/tailwind.css -o ./app/styles/tailwind.css", "build:remix": "remix build", - "build:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --minify --bundle --format=esm", - "build": "cross-env NODE_ENV=production run-s build:css build:worker build:remix build:server", - "start": "cross-env NODE_ENV=production node ./server.js", + "build:worker": "node ./scripts/build-worker.js", + "build": "NODE_ENV=production run-s build:css build:remix build:worker build:server", + "start": "NODE_ENV=production node ./server.js", "test": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint --ignore-path .gitignore --ext .js,.ts,.tsx .", @@ -66,7 +66,6 @@ "bullmq": "1.85.1", "clsx": "1.1.1", "compression": "1.7.4", - "cross-env": "7.0.3", "express": "4.18.1", "ioredis": "5.0.6", "isbot": "3.5.0", @@ -118,6 +117,7 @@ "esbuild": "0.14.42", "esbuild-node-externals": "1.4.1", "eslint": "8.16.0", + "glob": "7.2.3", "happy-dom": "5.0.0", "husky": "7.0.4", "lint-staged": "13.0.0", diff --git a/scripts/build-worker.js b/scripts/build-worker.js new file mode 100644 index 0000000..0426605 --- /dev/null +++ b/scripts/build-worker.js @@ -0,0 +1,67 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const glob = require("glob"); +const esbuild = require("esbuild"); + +const isDev = process.env.NODE_ENV !== "production"; +const basePath = process.cwd(); +const args = process.argv.slice(2); +const watch = args.includes("--watch"); + +const cacheVersion = isDev + ? "dev" + : (() => { + const manifests = glob.sync(path.join(basePath, "/public/build/manifest-*.js")); + const manifest = manifests.reduce((mostRecent, manifest) => + fs.statSync(manifest).mtime > fs.statSync(mostRecent).mtime ? manifest : mostRecent, + ); + return manifest.match(/manifest-(\w+).js/)[1].toLowerCase(); + })(); + +esbuild + .build({ + write: true, + outfile: path.join(basePath, "public", "entry.worker.js"), + entryPoints: [path.join(basePath, "app", "entry.worker.ts")], + format: "esm", + bundle: true, + define: { + ASSET_CACHE: `"asset-cache_${cacheVersion}"`, + DATA_CACHE: `"data-cache_${cacheVersion}"`, + DOCUMENT_CACHE: `"document-cache_${cacheVersion}"`, + }, + watch: watch + ? { + onRebuild(error, buildResult) { + const warnings = error?.warnings || buildResult?.warnings; + const errors = error?.errors || buildResult?.errors; + if (warnings.length) { + console.log(esbuild.formatMessages(warnings, { kind: "warning" })); + } + if (errors.length) { + console.log(esbuild.formatMessages(errors, { kind: "error" })); + + process.exit(1); + } + + console.log("Service worker rebuilt successfully"); + }, + } + : false, + }) + .then(({ errors, warnings }) => { + if (warnings.length) { + console.log(esbuild.formatMessages(warnings, { kind: "warning" })); + } + if (errors.length) { + console.log(esbuild.formatMessages(errors, { kind: "error" })); + + process.exit(1); + } + + console.log("Service worker build succeeded"); + }) + .catch((err) => { + console.error(err); + process.exit(1); + });