Created
September 10, 2024 09:59
-
-
Save edlaver/950519200aad563b4d82aa281d30a9b7 to your computer and use it in GitHub Desktop.
Sample service worker for quick caching of GET requests
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <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