Skip to content

Instantly share code, notes, and snippets.

@edlaver
Created September 10, 2024 09:59
Show Gist options
  • Save edlaver/950519200aad563b4d82aa281d30a9b7 to your computer and use it in GitHub Desktop.
Save edlaver/950519200aad563b4d82aa281d30a9b7 to your computer and use it in GitHub Desktop.
Sample service worker for quick caching of GET requests
/// <reference lib="WebWorker" />
// Based on: https://github.com/jacob-ebey/remix-pwa/blob/main/app/entry.worker.ts
//// Uses a modified version of: https://github.com/dumbmatter/promise-worker-bi (updated to work with service workers)
//// Co-ordinates with the client to fetch session tokens, and redirect if reauth required.
//// Parts taken from: ./web/frontend/hooks/useAuthenticatedFetch.js in the default node template
import { json } from "@remix-run/server-runtime";
import { getUnixTime, add } from "date-fns";
import { PWBWorker } from "../libs/promise-serviceworker-bi";
export type {};
declare let self: ServiceWorkerGlobalScope;
const promiseWorker = new PWBWorker();
const STATIC_ASSETS = ["/build/", "/icons/"];
const DATA_CACHE = "data-cache";
const X_SW_DATA_CACHE_ITEM_EXPIRES_AT = "x-sw-data-cache-item-expires-at";
const DATA_CACHE_TTL_SECONDS = 5;
function debug(...messages: any[]) {
if (process.env.NODE_ENV === "development") {
console.debug("entry.worker: ", ...messages);
}
}
async function handleInstall(event: ExtendableEvent) {
debug("Service worker installed");
}
async function handleActivate(event: ExtendableEvent) {
debug("Service worker activated");
}
async function handleMessage(event: ExtendableMessageEvent) {
debug("handleMessage", event);
if (event.data.type === "REMIX_NAVIGATION") {
debug(`event.data.type === "REMIX_NAVIGATION"`, event);
// let { isMount, location, matches, manifest } = event.data;
// let documentUrl = location.pathname + location.search + location.hash;
// ...
}
}
async function handleFetch(event: FetchEvent): Promise<Response> {
let url = new URL(event.request.url);
if (isAssetRequest(event.request)) {
// debug("handleFetch: isAssetRequest", event.request.url);
}
if (isLoaderRequest(event.request)) {
debug("handleFetch: isLoaderRequest", event.request.url);
const requestUrl = event.request.url;
const pathAndSearch = url.pathname + url.search;
// Do we have a cached version that we can use?
let cache = await caches.open(DATA_CACHE);
let cachedResponse = await caches.match(requestUrl);
if (cachedResponse) {
debug("Found cachedResponse for:", pathAndSearch);
// If so, is it fresh enough?
const cacheExpiresAt = Number(
cachedResponse.headers.get(X_SW_DATA_CACHE_ITEM_EXPIRES_AT) ?? "0"
);
const now = getUnixTime(new Date());
const isStillFresh = cacheExpiresAt > now;
// debug(cacheExpiresAt, now, isStillFresh, cacheExpiresAt - now);
if (isStillFresh) {
// If so, return it.
debug("Still fresh, returning cached response", pathAndSearch);
return cachedResponse;
} else {
// If not, remove it from the cache.
debug(
"Stale, removing cached response and getting a fresh one",
pathAndSearch
);
await cache.delete(requestUrl);
}
}
// Otherwise, fetch the data from the server.
try {
debug("Serving data from network", pathAndSearch);
// Add a Shopify bearer token to the request
// Request token from client side (which has access to AppBridgeContext)
try {
const response = await getSessionTokenAuthenticatedResponse(
event.request
);
// Add response to the cache with a short ttl header
const expiresAt = getUnixTime(
add(new Date(), { seconds: DATA_CACHE_TTL_SECONDS })
);
// const now = getUnixTime(new Date());
const responseHeaders = new Headers(response.headers);
responseHeaders.set(X_SW_DATA_CACHE_ITEM_EXPIRES_AT, `${expiresAt}`);
// debug(expiresAt, now, expiresAt - now);
const responseWithCacheHeader = new Response(response.body, {
headers: responseHeaders,
});
debug("Caching cloned response", pathAndSearch);
// Note: Have to clone the response going into the cache, otherwise we get an error about the body being locked.
await cache.put(event.request, responseWithCacheHeader.clone());
debug("Returning cloned response", pathAndSearch);
return responseWithCacheHeader;
} catch (error) {
// TODO: Handle error
debug("handleFetch: isLoaderRequest => promiseWorker error", error);
throw error;
}
} catch (error) {
debug("handleFetch: isLoaderRequest => error", error);
throw error;
}
}
if (isActionRequest(event.request)) {
debug("handleFetch: isActionRequest", event.request.url);
const pathAndSearch = url.pathname + url.search;
// NOTE: We don't cache action requests (e.g. POST).
// In fact, we clear the cache, so that any subsequent loaders are fresh
debug(
"handleFetch: isActionRequest => Clearing cache so subsequent loaders are fresh..."
);
await caches.delete(DATA_CACHE);
// Send the data from the server and get the response.
try {
debug("Serving data from network", pathAndSearch);
// Add a Shopify bearer token to the request
// Request token from client side (which has access to AppBridgeContext)
try {
const response = await getSessionTokenAuthenticatedResponse(
event.request
);
return response;
} catch (error) {
// TODO: Handle error
debug("handleFetch: isActionRequest => promiseWorker error", error);
}
} catch (error) {
debug("handleFetch: isActionRequest => error", error);
throw error;
}
}
if (isDocumentGetRequest(event.request)) {
// debug("handleFetch: isDocumentGetRequest", event.request.url);
}
// Default if no match above
return fetch(event.request.clone());
}
////////////////////////////////////////////////////////////////////////////////
/* Helper method for fetching a Shopify session token from the client side */
////////////////////////////////////////////////////////////////////////////////
async function getSessionTokenAuthenticatedResponse(
request: Request
): Promise<Response> {
const sessionToken = await promiseWorker.postMessage({
type: "SESSION_TOKEN_REQUEST",
requestUrl: request.url,
});
// debug("sessionToken", sessionToken);
const clonedRequest = request.clone();
const headers = new Headers(clonedRequest.headers);
// debug("Original headers: ");
// for (const header of headers.entries()) {
// debug("header: ", header);
// }
headers.set("Authorization", `Bearer ${sessionToken}`);
const authenticatedRequest = new Request(clonedRequest, {
headers,
mode: "cors", // Force mode to `cors`, otherwise prefetched requests don't get the auth header attached...
});
// Make the request from here instead of from the client,
// and check the response headers for X-Shopify-API-Request-Failure-Reauthorize* headers
// If found, send a new message to the client side to reauthorize using redirect.dispatch
// See: useAuthenticatedFetch hook in Shopify template.
let response = await fetch(authenticatedRequest);
if (
response.headers.get("X-Shopify-API-Request-Failure-Reauthorize") === "1"
) {
const authUrlHeader =
response.headers.get("X-Shopify-API-Request-Failure-Reauthorize-Url") ||
`/api/auth`;
await promiseWorker.postMessage({
type: "SHOPIFY_REAUTHORIZE_REQUEST",
authUrlHeader,
});
}
return response;
}
////////////////////////////////////////////////////////////////////////////////
/* Helper methods to determine what type the fetch request is, in Remix terms */
////////////////////////////////////////////////////////////////////////////////
function isMethod(request: Request, methods: string[]) {
return methods.includes(request.method.toLowerCase());
}
function isAssetRequest(request: Request) {
return (
isMethod(request, ["get"]) &&
STATIC_ASSETS.some((publicPath) => request.url.startsWith(publicPath))
);
}
function isLoaderRequest(request: Request) {
let url = new URL(request.url);
return isMethod(request, ["get"]) && url.searchParams.get("_data");
}
function isActionRequest(request: Request) {
let url = new URL(request.url);
return (
isMethod(request, ["post", "put", "patch", "delete"]) &&
url.searchParams.get("_data")
);
}
function isDocumentGetRequest(request: Request) {
return isMethod(request, ["get"]) && request.mode === "navigate";
}
////////////////////////////////////////////////////////////////////////////////
/* Register service worker event listeners */
////////////////////////////////////////////////////////////////////////////////
self.addEventListener("install", (event) => {
event.waitUntil(handleInstall(event).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(handleActivate(event).then(() => self.clients.claim()));
});
self.addEventListener("message", (event) => {
event.waitUntil(handleMessage(event));
});
promiseWorker.register(function (message: any) {
debug("promiseWorker (serviceworker) => message: ", message);
// TODO: Register actions for messages initiated by the client here:
// if (message.type === "PING") {
// return { type: "PONG" };
// } else {
// return "Response from serviceworker => ...";
// }
});
self.addEventListener("fetch", (event) => {
// debug("fetch", event.request.url);
event.respondWith(
(async () => {
let result = {} as
| { error: unknown; response: undefined }
| { error: undefined; response: Response };
try {
result.response = await handleFetch(event);
// debug("result.response", result.response);
} catch (error) {
result.error = error;
debug("result.error", error);
}
return appHandleFetch(event, result);
})()
);
});
async function appHandleFetch(
event: FetchEvent,
{
error,
response,
}:
| { error: unknown; response: undefined }
| { error: undefined; response: Response }
): Promise<Response> {
// debug("appHandleFetch", event, error, response);
return response ? response : json(error, { status: 500 });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment