When static meets SSR
So there I was, researching what features I wanted on this site, when I saw a recommendation to have a site-wide search. Since I decided on using Astro, the recommendation was to use astro-pagefind. Quick, easy, simple. Yet, I wasn’t satisfied.
What’s the problem?
There’s nothing critically problematic with the famous integration, but my focus for this project was mainly a tight performance and dependency budget, and I always highly value correctness.
The first issue I encountered was just a dislike of the way the original package works: you build your project, that generates an index for your entire site, and said index is served in dev mode. So it basically serves the index of the build version of your project, not the dev version.
It does make sense why it does that, though. Pagefind is a static site search, meant to run from CLI or Node API on files. When using Astro in dev mode, even if you’re making a static site, dev mode is served via SSR. I wasn’t a fan of this search index correctness issue, but my environments don’t really diverge, so it was tolerable.
The second part is more of a pagefind recommendation that the author of the original integration decided to follow. The quickest and simplest way to use pagefind is to simply use their UI. Make some imports, use their web components, and it just works™.
But how does it work, and what is required of me after the fact?
Pagefind UI
So I’ve imported the needed files, used the web components I want, and my search works. But my search UI looks nothing like the rest of my site. Looks like I have to use pagefind’s CSS variables in order to make their UI match mine.
But wait, I’m already relying on tailwind and daisyUI to make my UI, now I have to use something else and match all my themes and everything? That’s a ton of custom CSS.
Speaking of CSS, what exactly is pagefind doing for its styles? Here’s a small piece of the ~1500 lines of CSS:
:is(*, #\#):is(*, #\#):is(*, #\#) .pf-input:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-input:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-searchbox-input:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-searchbox-input:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-input-clear:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-input-clear:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-result-link:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-result-link:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-heading-link:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-heading-link:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-checkbox-input:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-checkbox-input:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-dropdown-trigger:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-dropdown-trigger:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-trigger-btn:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-trigger-btn:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-modal-close:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-modal-close:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-searchbox-result:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-searchbox-result:focus-visible,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-dropdown-options:focus,:is(*, #\#):is(*, #\#):is(*, #\#) .pf-dropdown-options:focus-visible { outline: none;}And even a nifty explainer at the top:
/* * Why all the :is(*, #\#) prefixes? * * These components are embedded in who-knows-what sites with unpredictable CSS. * We need our styles to win against host page selectors like `.content p` or * `article a:hover` without using !important everywhere. * * The :is(*, #\#) trick adds ID-level specificity (0,1,0) without requiring * an actual ID in the DOM. Chaining it (2x or 3x) builds enough specificity * to beat most host selectors. * * The #\# is an escaped # character, creating an invalid-but-harmless ID * selector that never matches, but still contributes specificity via :is(). * * We also need these overrides to only affect the Component UI elements themselves. * Users can provide custom templates to the results and searchbox components, * and these must inherit their styles without having to fight ours. * * It's admittedly a bit of a hack but it does provide best effort styling consistency * for the Component UI out in the wild. */So not only do I have to serve 1500 lines of CSS, I also have to add even more CSS to make it look like it belongs on my site. This seems like a workaround to me. If I just make my own UI, I get to drastically reduce CSS, reuse classes, own the components, customise the behaviours, and not have workarounds in my CSS. The only downside is extra work.
What does this mean for the integration?
So now that we know we want to make our own UI, let’s take a look at astro-pagefind again. It uses the recommended dependency @pagefind/component-ui, even though pagefind actually generates the CSS/JS files for their UI components when you run it.
This means that not only do we need to tree-shake the dependency out of our project, but we also might want to delete some files (no way around that one).
The reasons are piling up. For now we have:
- duplicated CSS/JS
- serving a lot of CSS
- customising additional UI to match other UI
- using a build index instead of a dev index
This was enough for me to make my own version.
Feature planning
We’re going to plan and optimise around fewer dependencies, and the assumption that a user primarily wants to build their own UI.
First things first, I have no complaints about the build-time functionality, works great, doesn’t need changing. It could however benefit from optionally removing pagefind UI build artifacts.
Since we’re building for UI makers and dependency haters, the extra @pagefind/component-ui library is out. We’ll have to figure out how to support component-ui usage without it.
We’re also going to serve the dev index in dev mode. That’s going to be the tricky part.
Alright, let’s get into the meat and potatoes.
Pagefind API
We already know pagefind needs HTML files in order to build an index. We can note that pagefind has a method called addHTMLFile on its index object. We can also note that Astro renders HTML on demand in dev mode.
This means we’ll need to incrementally build our index in a middleware as we render each HTML page.
At the time of working on it, I couldn’t even find a reasonable way to get all possible routes as info in the integration context. So it would be too much trouble to try to fetch all routes to populate the index fully on the integration side. It’s always possible for users that actually know their routes to make their own little integration to populate the index in dev mode.
Not that I believe a full index is that useful in dev mode anyway.
Our problem is file changes. Pagefind does not have a way to update an index, meaning we’ll have to rebuild the entire index when a file that’s already in the index changes - which means we need a cache for seen pages.
Pagefind API also exposes a cool method to get the output files in-memory. We could take advantage of this for some performance wins. Though it will require going deeper into Vite, the bundler Astro uses.
Vite magic
Vite is the star of the show here, to make sure we don’t have to manipulate the filesystem in order to provide our functionality. As we (re)generate our search index, we’ll have to store it somewhere (more on that later), and Vite allows us to pull from that memory when the client code imports pagefind.js for the UI work.
So what we’ll do is create a Vite plugin:
{ name: "vite-plugin-pagefind", applyToEnvironment(env) { return env.name === "client"; }, resolveId: { filter: { id: new RegExp(`^${VIRTUAL_PAGEFIND_MODULE_ID}$`) }, // virtual:pagefind handler() { return VIRTUAL_PAGEFIND_RESOLVED_MODULE_ID; }, // \0virtual:pagefind }, load: { filter: { id: new RegExp(`^${VIRTUAL_PAGEFIND_RESOLVED_MODULE_ID}$`) }, // \0virtual:pagefind async handler() { const file = await state.getFile("pagefind.js"); if (!file) return;
return { code: Buffer.from(file.content).toString("utf8"), map: null, }; }, }, transform(code, id) { if (id !== VIRTUAL_PAGEFIND_RESOLVED_MODULE_ID) return; return { code, map: null }; }}Let’s take it step by step:
applyToEnvironmentFirst we make sure our plugin only runs in the client. Server imports are ignored and produce an error.resolveIdWe map the import string to our identifier for the module.loadWe map our identifier to the actual code we stored in-memory.transformSince our code is external, we want to avoid Vite making any changes to it.
This should work fine for dev, but won’t work for builds. Since when we build, we actually have output files, and we don’t need an elaborate system.
Conclusion? We’ll use this Vite plugin only in dev mode, and we’ll need a way to tell Vite that importing pagefind.js is not an error when we build our project.
Updates
The most expensive part of our data flow is definitely page updates. Since pagefind index is append-only, we end up having to store our seen HTML in memory. This is so we can rebuild the index when a page changes, and not have to start over from a near-empty index.
And it doesn’t end there. Another interesting part to think about is: when a page changes, and consequently the index - we need to actually tell the client that the already loaded index is stale. The client library has to re-request and reload the search index.
For our integration to be “complete”, we need to manage that index reload on the client - otherwise we’re leaking implementation to the user. This calls for a wrapper over the client module, one that handles the reload. An additional benefit is that we can either serve the wrapper in dev or “point to” the build artifact for the build.
We end up with an additional Vite plugin like so:
{ name: "vite-plugin-pagefind-client", applyToEnvironment(env) { return env.name === "client"; }, resolveId: { filter: { id: CLIENT_MODULE_PATTERN }, // any pattern ending in "pagefind.js" async handler(source, importer, _options) { // if not in dev mode, mark the import as external if (command !== "dev") return { id: source, external: true };
// otherwise serve our client wrapper const file = fileURLToPath( new URL("../client/index.js", import.meta.url), ); const resolved = await this.resolve(file, importer, { skipSelf: true });
return resolved ?? { id: file }; }, }}Note: command comes from our Astro context, so we know which command our user ran.
And our client file essentially mirrors the exact API of the pagefind.js client module, with some extra bookkeeping and a handler for Vite’s Hot Module Replacement events. We’ll use Vite’s import.meta.hot.on() to handle a custom event that we’ll send from the server when we see a change happen.
You can see the wrapper in action on this GitHub link.
Async state management
This is the most complicated part of the entire integration, also the part that requires good tests to make sure we can avoid regressions.
We can create the overview of the file quickly. We’ve already mentioned that we need the following:
- to store our generated pagefind files in memory
- to store a cache of seen HTML pages in memory (for index rebuilds)
- a way to let the client know that a change has happened
Additionally, we’ll need to know when there is active work to wait on (simply the nature of wanting it to work async). Other than that, we have the obvious things like the pagefind configuration object and the pagefind index object.
const _emitter = new EventEmitter<{ change: [] }>();let _config: PagefindConfig = {};let _index: PagefindIndex | null = null;let _files = new Map<string, PagefindFile>();const _cache = new Map<string, string>();let _workActive = Promise.resolve();
// optimisationslet _timer: NodeJS.Timeout | null = null;let _generation = 0;Now, most of the setup should make sense. Emitters, objects, Maps, but then we see _workActive. I decided to go with a promise here, it makes it pretty clear when we’re waiting for active work:
async function getFile( fileName: string,): Promise<PagefindFile | undefined> { await _workActive; const file = _files.get(fileName); return file;}We use getFile when we want to serve the files to the client. So when the client requests pagefind files, if anything is currently happening to the index, the dev server waits for that work before serving the latest version of the files.
The work queue
Another benefit is that it’s very easy to schedule more work right away, without disrupting current work. Simply add to the chain.
_workActive = _workActive.then(moreWork);But, quickly we realise a problem with this approach. If we wait on all work, and only chain more, we never get to stop runs on stale or duplicate data. This is where our optimisations come in.
We have two possible scenarios for indexing HTML. When we index a page we haven’t seen before, we simply append to the index; we call this an _add.
The other scenario is that we need to index a page that’s already indexed. Either the content is the same, so we skip work, or we update our cache with the updated page and we remake a whole new index with the entire cache. We call this one _rebuild.
The shared behaviour is that they both need to store the files in state upon finishing, and to emit an event to the client. Before you worry about this one, since we’re always chaining, data races aren’t exactly feasible.
Debouncing rebuilds
Our timer was made to handle running a rebuild multiple times in a row in quick succession. Yes, it’s just used for a debounce.
For cases like Astro’s ClientRouter, you might get multiple requests to preload pages in a short timeframe. In these cases the only really important part is storing the page in the cache. Everything after is performance. In our case we debounce the call to _rebuild after we store the page in cache.
function addHtmlFile(htmlFile: { content: string; url: string;}): Promise<void> { const { content, url } = htmlFile;
const existingHtml = _cache.get(url); if (existingHtml === content) return _workActive;
_cache.set(url, content);
const isUpdate = existingHtml !== undefined; if (!isUpdate) { _workActive = _workActive.then(() => _add(htmlFile)); return _workActive; }
if (_timer) { _timer.refresh(); } else { const rebuildPromise = new Promise<void>((resolve) => { _timer = setTimeout(async () => { _timer = null; await _rebuild(); resolve(); }, 150); });
_workActive = _workActive.then(() => rebuildPromise); }
return _workActive;}Cancelling stale work
The other part you might be wondering about is what _generation is. This is a way to handle cancellation of async work.
I purposefully use a shared integer, I copy its value at the start of a context and I check its value again before any expensive work is carried out. If the value is not the same right before the work is about to run, that means some other async function is pending (in our case it’s a new _rebuild call, turning this one obsolete), and we can exit our function without doing the work.
async function _rebuild() { const gen = ++_generation; const index = await _createIndex(_config.indexConfig); if (!index) return; if (gen !== _generation) return;
await Promise.all( _cache .entries() .map(([url, content]) => index.addHTMLFile({ url, content })) .toArray(), ); if (gen !== _generation) return;
_index?.deleteIndex(); _index = index; const files = await _getFiles(index); if (gen !== _generation) return;
_setFiles(files); _emitChangeEvent();}Now, while these mechanisms do work, they help a lot to increase the performance, reduce the latency and hardware usage… It’s quite a pain to look at. I feel like there should be a better way to do this.
Cancellation is a very cool tool, and even that I could probably improve by writing a scheduler. I haven’t worked out all of the details yet, but it’s obvious that appends and rebuilds have to be treated differently.
Many consecutive appends don’t remake an index, so they can all run, even concurrently. But rebuilds make a new index, so all old index work is redundant. This also applies to a rebuild queued up while another is still running - the earlier one becomes redundant.
Once I’ve decided on the details, I’ll either add an update here, or make a whole new blog post about it.
Bonuses
If we pull back to the bigger picture, I’d just quickly mention some easy wins for this integration.
Since our client wrapper uses the same API as pagefind, and pagefind doesn’t do a great job at exposing types in its output, we can just write the types out ourselves. Then they get injected via the integration into the user project, giving our users pagefind client types and autocomplete DX.
The other win is for pagefind-ui haters: we have an explicit flag in the configuration called ui. If set to false by the user, we can remove all generated UI files, making sure users aren’t shipping files they aren’t going to use.
Possible additions
Let’s quickly list what we have:
- building and emitting files for builds
- serving in-memory index in dev mode
- types
- opt-in removal of UI files
The one thing we don’t optimise for at all, but technically we could: There’s no way to use pagefind-ui files via import, and this is by design. For now. Maybe I could be convinced otherwise.
When you think about it, they really are third-party files that make no sense to go through the bundler. Simply add them as a <script> tag and it’ll work fine.
But, technically we could make more Vite plugins and make them accessible via import.
In closing
I’d encourage anyone who isn’t satisfied with a tool, especially when the tool is small: Make your own version.
The more you do this, the better you’ll understand your craft, and the better you’ll understand what it is like to make tools.
Sometimes you can extend them and make something more pleasant to use (at least for yourself). But more often, you tend not to need all the capabilities a project has to offer.
Most popular open-source projects can’t afford to have a narrow use case. Too many people depend on them. But most of the time, we have narrower use cases than the projects provide. Knowing not only when it’s a good idea to make your own trimmed version, but also that you can do it right, will definitely be a strong addition to your career toolbelt.
And doesn’t feel too bad, either.