Service Worker Side template rendering
I've recently been inspired by The AHA Stack and wanted to look into building something with it. Astro in particular reminds me of building with 11ty, it's just not (necessarily) building a static site. But I also want to look into more offline-capable web development, which is pretty much impossible with server-side rendering like Astro. If only I could get Astro to run on the client side in a service worker...
I did look into that possibility and found one attempt, which, if I'm honest, went mostly over my head. My takeaway is that it wasn't yet feasible back in 2022 and while things may have changed now, no one else has pursued the idea. I don't think I have the expertise to pull something like that off, but I was inspired to look into perhaps doing something similar, but more simply.
So, I've taken a crack at something... I don't know how useful it is to everyone else, but I think it might have some legs. I've got a server set up with some Mustache templates, then I have a web page with HTMX set up to request those templates alongside data from a JSON endpoint. For this example I'm using the omg.lol API to get statuses (I'm toying with the idea of rebuilding neighbourhood.omg.lol with this stack, but that's another story). So on my HTMX-enabled HTML page, I have included this:
<section id="statuses" hx-trigger="load" hx-swap="innerHTML" hx-get="/templates/statuses.mustache" hx-vals='{"@url":"https://api.omg.lol/statuslog/latest"}'></section>
This triggers a request to /templates/statuses.mustache
, with @url=https://api.omg.lol/statuslog/latest
encoded into the query parameters (if I'd used hx-post
, it would be passed through the body). This request will be picked up and intercepted by my service worker like this:
self.addEventListener('fetch', async event => {
if(new URL(event.request.url).pathname.startsWith('/templates')){
event.respondWith(templates(event.request))
}
else {
//TODO: serve other stuff from cache
event.respondWith(fetch(event.request));
}
});
(I could and perhaps should change this to check for a pathname ending in .mustache
rather than starting with /templates
, but I'm still just playing around here).
The important thing here is obviously the call to templates
, which I will break down into pieces. The first thing it does is ditch any parameters and get the template file itself:
async function templates(request) {
const url = new URL(request.url)
const templateUrl = new URL(url.pathname, url.origin)
// first try and get the template
const templateres = await getTemplate(templateUrl)
if(!templateres.ok) return templateres // return the result on failure
let template = await templateres.text()
...
The getTemplate
function call there is just a basic "check if it's in the cache, if not go fetch it" kind of function:
async function getTemplate(url) {
// first check the cache
const cache = await caches.open(TEMPLATE_CACHE)
let response = await cache.match(url)
// if not in cache, fetch it and put it in the cache
if(!response){
response = await fetch(url)
if(response.ok) cache.put(url, response.clone())
}
return response
}
Once I have the mustache template in hand, I need the data to populate the template. I've built it so that you can either pass data through the request, or fetch the data from elsewhere using @url
and other @
-prefixed parameters to build up a request. At the moment I'm assuming such a request will return JSON, but this should probably be made more explicit in the future.
...
let data = {}
let requrl = null
let reqinit = {}
let params = {}
// check if anything was passed through search params
if(url.searchParams.size > 0) params = parseSearchParams(url.search)
// check if anything was passed through the body
const body = await request.text().trim()
if(body) params = {...params, ...parseSearchParams(body)}
// @url is a url to fetch data from
// @<other> is a request init param
// everything else is data for the template
Object.entries(params).forEach(([key, value]) => {
if(key == '@url') requrl = value
else if(key.startsWith('@')) reqinit[key.slice(1)] = value
else data[key] = value
})
// if we have a request url, go fetch data from the request
// TODO: caching?
if(requrl) {
const responsedata = await fetch(requrl, reqinit).then(r => r.json())
data = {...data, ...responsedata}
}
...
finally, once we have the data (either from the request or passed through), we can use that to render the template and return the rendered HTML as a response for HTMX to swap in
...
// render the template with any data we have
const output = Mustache.render(template, data)
// finally, create and return the response
return new Response(output, {
status:200,
headers:{ "Content-Type": "text/html" }
})
}
Et voilà, service-worker-side rendering from templates stored on the server and cached locally. Obviously there's a lot of room for improvement here, especially to do with the caching strategies. But I can imagine building a PWA like this, with locally cached templates and the ability to fetch data from an external API.
I am very tempted to continue working on this, but I would also like to know how useful this is. Is this just something fun, or are there practical applications? Is there a future for service workers beyond caching strategies? Please, let me know. These are discussions I want to be having.