* fix "dev:build" watch mode

* remove cross-env
* append build hash to service worker cache names for easy purge
This commit is contained in:
m5r 2022-06-11 15:13:28 +02:00
parent 836b1d8d1b
commit 1e9b7a8aa2
12 changed files with 137 additions and 76 deletions

View File

@ -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()));

View File

@ -53,7 +53,6 @@ export default function ServiceWorkerUpdateNotifier() {
aria-label="An updated version of the app is available. Reload to get the latest version."
/>
</button>
;
</div>
);
}

View File

@ -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");

View File

@ -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<Response> {
export function fetchAsset(event: FetchEvent): Promise<Response> {
// stale-while-revalidate
const url = new URL(event.request.url);
return caches
@ -53,7 +53,7 @@ export function cacheAsset(event: FetchEvent): Promise<Response> {
// stores the timestamp for when each URL's cached response has been revalidated
const lastTimeRevalidated: Record<string, number> = {};
export function cacheLoaderData(event: FetchEvent): Promise<Response> {
export function fetchLoaderData(event: FetchEvent): Promise<Response> {
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<boolean> {
return true;
}
export function cacheDocument(event: FetchEvent): Promise<Response> {
export function fetchDocument(event: FetchEvent): Promise<Response> {
// 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<Response> {
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");
}

View File

@ -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);

View File

@ -1,4 +1,4 @@
declare let self: ServiceWorkerGlobalScope;
declare const self: ServiceWorkerGlobalScope;
export default async function handleInstall(event: ExtendableEvent) {
console.debug("Service worker installed");

View File

@ -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<string, Promise<void>> = 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<string, Promise<void>> = 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)));
}

View File

@ -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) {

View File

@ -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",

41
package-lock.json generated
View File

@ -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"
}

View File

@ -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",

67
scripts/build-worker.js Normal file
View File

@ -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);
});