diff --git a/app/features/core/hooks/use-service-worker-revalidate.ts b/app/features/core/hooks/use-service-worker-revalidate.ts new file mode 100644 index 0000000..d86edf0 --- /dev/null +++ b/app/features/core/hooks/use-service-worker-revalidate.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useFetcher } from "@remix-run/react"; + +export default function useServiceWorkerRevalidate() { + const fetcher = useFetcher(); + + useEffect(() => { + const channel = new BroadcastChannel("sw-messages"); + function onMessage(event: MessageEvent) { + const isRefresh = event.data === "revalidateLoaderData"; + if (isRefresh) { + console.debug("Revalidating loaders data"); + fetcher.submit({}, { method: "post", action: "/dev/null" }); + } + } + + channel.addEventListener("message", onMessage); + return () => { + channel.removeEventListener("message", onMessage); + channel.close(); + }; + }, [fetcher]); +} diff --git a/app/routes/__app.tsx b/app/routes/__app.tsx index a184a77..f2ac361 100644 --- a/app/routes/__app.tsx +++ b/app/routes/__app.tsx @@ -4,6 +4,7 @@ import { Outlet, useCatch, useMatches } from "@remix-run/react"; import serverConfig from "~/config/config.server"; import { type SessionData, requireLoggedIn } from "~/utils/auth.server"; import Footer from "~/features/core/components/footer"; +import useServiceWorkerRevalidate from "~/features/core/hooks/use-service-worker-revalidate"; import footerStyles from "~/features/core/components/footer.css"; import appStyles from "~/styles/app.css"; @@ -29,6 +30,7 @@ export const loader: LoaderFunction = async ({ request }) => { }; export default function __App() { + useServiceWorkerRevalidate(); const matches = useMatches(); const hideFooter = matches.some((match) => match.handle?.hideFooter === true); diff --git a/app/routes/dev.null.ts b/app/routes/dev.null.ts new file mode 100644 index 0000000..06fd308 --- /dev/null +++ b/app/routes/dev.null.ts @@ -0,0 +1,5 @@ +import type { ActionFunction } from "@remix-run/node"; + +export const action: ActionFunction = async ({ request }) => { + return null; +}; diff --git a/app/service-worker/activate.ts b/app/service-worker/activate.ts index b45d317..6869b38 100644 --- a/app/service-worker/activate.ts +++ b/app/service-worker/activate.ts @@ -1,3 +1,5 @@ +import { deleteCaches } from "./cache-utils"; + declare let self: ServiceWorkerGlobalScope; export default async function handleActivate(event: ExtendableEvent) { @@ -7,4 +9,6 @@ export default async function handleActivate(event: ExtendableEvent) { // @ts-ignore await self.registration.navigationPreload.enable(); } + + await deleteCaches(); } diff --git a/app/service-worker/cache-utils.ts b/app/service-worker/cache-utils.ts index 372665c..c92daf6 100644 --- a/app/service-worker/cache-utils.ts +++ b/app/service-worker/cache-utils.ts @@ -17,7 +17,7 @@ export function isDocumentGetRequest(request: Request) { return request.method.toLowerCase() === "get" && request.mode === "navigate"; } -export function cacheAsset(event: FetchEvent) { +export function cacheAsset(event: FetchEvent): Promise { // stale-while-revalidate const url = new URL(event.request.url); return caches @@ -50,36 +50,183 @@ export function cacheAsset(event: FetchEvent) { }); } -export function cacheLoaderData(event: FetchEvent) { - // network-first - const url = new URL(event.request.url); - console.debug("Serving data from network", url.pathname + url.search); +// stores the timestamp for when each URL's cached response has been revalidated +const lastTimeRevalidated: Record = {}; - return event.preloadResponse - .then((preloadedResponse?: Response) => preloadedResponse || fetch(event.request.clone())) - .then((response) => - caches - .open(DATA_CACHE) - .then((cache) => cache.put(event.request, response.clone())) - .then(() => response), - ) - .catch(() => { - console.debug("Serving data from network failed, falling back to cache", url.pathname + url.search); - return caches.match(event.request).then((response) => { - if (!response) { - return json( - { message: "Network Error" }, - { - status: 500, - headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" }, - }, - ); - } +export function cacheLoaderData(event: FetchEvent): Promise { + /*if (searchParams.get("_refresh") === "groot") { + console.debug("Serving refreshed data from network", url.pathname + url.search); + return event.preloadResponse + .then((preloadedResponse?: Response) => preloadedResponse || fetch(event.request.clone())) + .then((response) => + caches + .open(DATA_CACHE) + .then((cache) => cache.put(event.request, response.clone())) + .then(() => + response + .clone() + .json() + .then(({ json }) => console.debug("ddd", json?.phoneCalls?.[0]?.recipient)), + ) + .then(() => { + console.debug("returned latest", Date.now()); + return response; + }), + ) + .catch(() => { + console.debug("Serving data from network failed, falling back to cache", url.pathname + url.search); + return caches.match(url).then((response) => { + if (!response) { + return json( + { message: "Network Error" }, + { + status: 500, + headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" }, + }, + ); + } - response.headers.set("X-Remix-Worker", "yes"); - return response; + response.headers.set("X-Remix-Worker", "yes"); + return response; + }); }); - }); + }*/ + + /*return caches.match(event.request, { cacheName: DATA_CACHE }).then((cachedResponse) => { + console.debug(`Serving data from ${cachedResponse ? "cache" : "network"}`, url.pathname + url.search); + cachedResponse?.headers.set("X-Remix-Worker", "yes"); + + const fetchPromise = event.preloadResponse + .then((preloadedResponse?: Response) => preloadedResponse || fetch(event.request.clone())) + .then((response) => + caches.open(DATA_CACHE).then((cache) => { + response.text().then(rrr => console.log(response.ok, url.pathname + url.search, rrr)); + if (!response.ok) { + return json( + { message: "Network Error" }, + { + status: 500, + headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" }, + }, + ); + } + + cache.put(event.request, response.clone()); + const timestamp = lastTimeResponded[url.pathname + url.search]; + console.log("timestamp - Date.now()", Date.now() - timestamp); + + /!*if (timestamp && (Date.now() - timestamp > 10 * 1000)) { + console.debug("update UI with latest", Date.now()); + // we already returned the cached response + // we need to update the UI with the latest data + const message = { + type: "revalidateLoaderData", + // href: url.pathname + "?_refresh=groot", + }; + const channel = new BroadcastChannel("sw-messages"); + channel.postMessage(JSON.stringify(message)); + }*!/ + + return response; + }), + ); + + if (cachedResponse) { + console.debug("returned cached", Date.now()); + lastTimeResponded[url.pathname + url.search] = Date.now(); + } + return fetchPromise.then(response => { + console.debug("returned networked", Date.now()); + lastTimeResponded[url.pathname + url.search] = Date.now(); + return response; + }) + });*/ + + const url = new URL(event.request.url); + const path = url.pathname + url.search; + + return caches.match(event.request, { cacheName: DATA_CACHE }).then((cachedResponse) => { + console.debug(`Serving data from ${cachedResponse ? "cache" : "network"}`, path); + cachedResponse?.headers.set("X-Remix-Worker", "yes"); + + const timestamp = lastTimeRevalidated[path] ?? 0; + const diff = Date.now() - timestamp; + const TEN_SECONDS = 10 * 1000; + if (cachedResponse && diff < TEN_SECONDS) { + console.debug("Returned response from cache after a revalidation no older than 10s"); + // TODO: see if we can check a header or something to see if the requests comes from the revalidation thing + return cachedResponse; + } + + const fetchPromise = event.preloadResponse + .then((preloadedResponse?: Response) => preloadedResponse || fetch(event.request.clone())) + .then((response) => + caches.open(DATA_CACHE).then((cache) => { + if (!response.ok) { + return json( + { message: "Network Error" }, + { + status: 500, + headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" }, + }, + ); + } + + const clonedResponse = response.clone(); + cache.match(event.request).then(async (cached) => { + if (!cached) { + // we had nothing cached, simply cache what we got + await cache.put(event.request, clonedResponse.clone()); + return; + } + + if (await areResponsesEqual(cached.clone(), clonedResponse.clone())) { + // if what we have in the cache is up-to-date, we don't have to do anything + console.debug("Responses are the same, no need to revalidate", path); + return; + } + + // otherwise, cache the new response + await cache.put(event.request, clonedResponse.clone()); + + if (cachedResponse) { + // and if we had returned a cached response + // tell the UI to fetch the latest data + console.debug("Revalidate loader data", path); + const channel = new BroadcastChannel("sw-messages"); + channel.postMessage("revalidateLoaderData"); + lastTimeRevalidated[path] = Date.now(); + } + }); + + return response; + }), + ); + + return cachedResponse || fetchPromise; + }); +} + +async function areResponsesEqual(a: Response, b: Response): Promise { + const viewA = new DataView(await a.arrayBuffer()); + const viewB = new DataView(await b.arrayBuffer()); + + if (viewA === viewB) { + return true; + } + + if (viewA.byteLength !== viewB.byteLength) { + return false; + } + + let i = viewA.byteLength; + while (i--) { + if (viewA.getUint8(i) !== viewB.getUint8(i)) { + return false; + } + } + + return true; } export function cacheDocument(event: FetchEvent): Promise { @@ -104,3 +251,9 @@ export function cacheDocument(event: FetchEvent): Promise { }), ); } + +export async function deleteCaches() { + console.debug("Caches deleted"); + const allCaches = await caches.keys(); + await Promise.all(allCaches.map((cacheName) => caches.delete(cacheName))); +} diff --git a/app/service-worker/fetch.ts b/app/service-worker/fetch.ts index bedd28a..7ed8cfd 100644 --- a/app/service-worker/fetch.ts +++ b/app/service-worker/fetch.ts @@ -5,7 +5,7 @@ import { isAssetRequest, isDocumentGetRequest, isLoaderRequest, -} from "~/service-worker/cache-utils"; +} from "./cache-utils"; declare let self: ServiceWorkerGlobalScope;