<activity-graph> (Web) Component
I like concrete New Year's resolutions: For the third year in a row, I'll reduce the amount of meat I eat ("meating"?) to max. 6 times a year. This year I have added another resolution: Do some form of fitness workout at least 5 times a week.
While the first resolution was for environmental and ethical reasons, the second was because I felt unhealthy and was afraid of letting my body deteriorate. To increase my motivation, I did two things:
- I bought an Apple Watch and told everyone I'd sell it if I missed my workout targets.
- I made a bet with my colleague Philipp: if I hit my goals, he would make lunch for us for a week - if I missed it, I would.
The bet helped me to make the rules more concrete:
- Sick days reduce the goal in a week, holidays don't.
- Explicit training (no cycling or walking).
- Doing one workout in the morning and one in the evening counts as two.
- Additional workouts in one week do not carry over to the next.
The presence of my Apple Watch somehow indicates when I'm on the course. But for my bet with Philipp, after some time I wanted to make my progress more transparent. I decided to build a web component that mimicked the look and feel of GitHub's contribution graph.
Phase 1: Web component
To be honest - I completely underestimated this project. Wrestling with tables and data while trying to make things as flexible and accessible as possible was a lot more work than I expected (LLM's help included). But before we go any further, let's have a look at the end result:
What you see here is the result of the <activity-graph>
web component, now available via npm and GitHub. It has a bunch of features (date range, custom levels, first day of week), supports basic i18n, includes theming and easy styling (LightDOM only!), and relies on semantics for a11y. All these features make it easy to customize different use cases, e.g. here to visualize my meat consumption...
...generated with the following HTML:
<activity-graph
range-start="2024-01-01"
range-end="2024-12-31"
activity-data="2024-01-14,2024-03-15,2024-09-04,2024-09-12"
activity-levels="0,1"
first-day-of-week="1"
i18n='{"less":"๐ฑ No Meat","more":"๐ Meat"}'
></activity-graph>
In general, I'm a big proponent of progressive enhancement, so I added the ability to "slot" elements with data-activity
attributes, which are read and then overridden by the component via JavaScript.
Of course the rendering happens in the browser โย but it makes sense at this point, to take a look at how the web component is built:
-
activity-graph.js
is a web component that reads attributes from the DOM. -
activity-graph-element.js
is a pure function that gets the attributes from the web component and returns HTML and CSS.
Phase 2: Server-side rendering in Node.js
The concept of extracting and naming activity-graph-element.js
didn't come from me: It's shaped by the team around enhance.dev:
Enhance is an HTML-first, full-stack web framework that gives you everything you need to build standards-based, multi-page web applications that perform and scale.
To be honest, I have never used the framework. I'm happy using PHP-based Statamic for my website, Astro for others, and prefer Laravel for apps. Nevertheless, Enhance caught my attention with their article about how they enable slotting in web components while using the Light DOM with @enhance/custom-element and their approach in @enhance/ssr which allows to render JavaScript based components on the server.
Specifically, they allow two types of components to be server-side renderable:
- Web components using @enhance/custom-element as a base class, which "allow[s] you to take advantage of slotting and style scoping in the light DOM" (docs).
- Elements that are "defined with a pure function that returns the HTML, CSS, and JavaScript you want to encapsulate within that component" (docs).
Sounds familiar, doesn't it? That's exactly what I did with activity-graph-element.js
, and indeed: this allows the entire component to be rendered on the server immediately using @enhance/ssr
:
Wrapping up this concept: A pure function that provides HTML, CSS (and optionally even JavaScript) can be used for
- Server-side rendering with
@enhance/ssr
without the need for JS in the browser, or - Client-side rendering by wrapping a tiny web component around it (for
<activity-graph>
71 LOCs including Progressive Enhancement and reactivity).
If you look at my demo page for <activity graph>
, you will see both rendering types living happily side by side. This is amazing - as long as you have Node.js by hand to run @enhance/ssr
, which I don't, e.g. for my personal website. So let's move on to the final phase.
Side Quest: Running Node.js services in PHP
Over the last few months I have been thinking and experimenting a lot about how to integrate @enhance/ssr
or other Node scripts (e.g. for minifying HTML) into my website. In the end, it all came down to sending HTML to a Node.js service that returns the pre-rendered HTML.
Shell
Using exec()
you can easily run a shell script like node scripts/enhance.js --input [html]
, where scripts/enhance.js
is a prepared script that includes @enhance/ssr
and defines all the components. There are some flaws:
- You need to have Node.js installed on your server.
- It takes some time to spin up
node
. - You can't send an unlimited number of characters as arguments.
For my personal site, I would have been fine with installing Node.js, and I'm sure I could have found ways around the performance problem - but the latter is an absolute deal-breaker: Even my not-so-large blog pages were exceeding the allowed character count.
Node.js I/O Server
Instead of spinning up Node.js on every request, I experimented with having a small Node.js endpoint that accepts the HTML inside a POST
request. I tried this locally with Fastify and it was... fast. But there were other problems:
- You have to run and maintain a Node.js server (or use e.g. AWS Lambda).
- You have to rethink repo management, version control, etc.
I especially liked (and still like) the idea of making this server publicly available to have an "enhancing" service for all my projects. My main problem was repo management and that this approach would break up my monolith. Still, I was so close to going that way. It turned out differently.
WASM
I mentioned @enhance/ssr
in a discussion on Mastodon when Brian LeRoux from the Enhance team jumped in and said that they were working on making the renderer available for other programming languages via WASM and that they already had it working for Python, for example. I did some research myself and found Extism, which honestly is an amazing technology. What you can do with it is more or less the following:
- You write code in one of the 8 languages for which a PDK library is available (e.g. Rust, JS, Go). Your custom code is compiled with the library into an executable WASM binary that can take arguments.
- In your application or website you import the Extism SDK which is available for 14 languages (e.g. Java, Go, Rust, JS, Ruby, Python... and PHP!). The SDK allows you to run your created binary with your arguments.
For me this meant: Write a JS function, compile it to WASM, and run it in PHP. I was so excited that I tried it the next evening and quickly got enhance-ssr-wasm
prototypes working in PHP, Node and Java. I'm still amazed that the Enhance team soon switched their approach to Extism based on a fork of mine.
Phase 4: Server-side Rendering via WASM
So the basics were there and the process was straightforward:
- Download
enhance-ssr-wasm.wasm
into my project. -
composer install extism-php
. -
npm install @mariohamann/activity-graph
. - Set up a middleware in Laravel/Statamic right before returning the response, integrate Extism by following the docs for the PHP SDK and wire everything up.
This was my first draft and I thought to finish it quickly.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Extism\Plugin;
use Extism\Manifest;
use Extism\PathWasmSource;
class Enhance
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$wasmPath = base_path("vendor/enhance/ssr-wasm/enhance-ssr.wasm");
$jsPath = base_path("node_modules/@mariohamann/activity-graph/dist/activity-graph-element.js");
$wasm = new PathWasmSource($wasmPath);
$manifest = new Manifest($wasm);
$enhance = new Plugin($manifest, true);
$input = [
"markup" => $response->getContent(),
"elements" => [
"activity-graph" => file_get_contents($jsPath),
],
];
$output = $enhance->call("ssr", json_encode($input));
$newResponse = json_decode($output)->document;
$response->setContent($newResponse);
return $response;
}
}
Oh, I've been so wrong.
Missing locales in QuickJS
When I rendered my component for the first time, it rendered only dates instead of weekdays or months, e.g. 2023/10/12
instead of Mon
(for Monday) or Jan
(for January). Apparently, e.g. Date.toLocaleString((lang, {weekday: "short"})
didn't work in my compiled Extism plugin. I did some research on QuickJS, which is the JavaScript runtime Extism uses in their PDK, found test262.fyi and realized that Intl Locale Info
was (and is) 0% implemented in QuickJS.
As a solution, I decided to create the wrapper activity-graph-wasm.js
, which polyfills Date.toLocaleString()
within activity-graph-element.js
using dayjs.
Of course, in some cases
dayjs
works differently from the browser's implementation. For example, in German, dots have been added to the abbreviations, e.g.Mon.
instead ofMon
.
See the file src/activity-graph-wasm.js
and src/activity-graph-element.js, L9
for further details on my implementation.
Unfortunately, this approach has led to other problems.
Performance of large elements
The Extism plugin around enhance-ssr-wasm
expects a single string which contains a JSON in the following format:
$jsPath = base_path("node_modules/@mariohamann/activity-graph/dist/activity-graph-element.js");
$input = [
"markup" => $response->getContent(),
"elements" => [
"activity-graph" => file_get_contents($jsPath),
],
];
$output = $enhance->call("ssr", json_encode($input));
The critical part here is that elements must be self-contained functions in a single string. So I had to set up build scripts that bundle the locales provided by dayjs
. I noticed that the processing time of the Extism plugin in PHP increased with the size of the file - and a bundle with all locales provided by dayjs
increased the execution time to >1 second, which was added to every page request.
To solve this problem I did the following:
-
Filtering: I started the enhancement process only if the markup contains
<activity-graph>
. -
Caching: I cached the combination of markup, WASM file and
activity-graph-wasm.js
. - Bundling: I created minified bundles for each locale to reduce the file size of the script.
And suddenly... I was done (check out my current middleware on GitHub). Edit: I thought I was done at this point, but after I published this blog article, @flori@metalhead.club pointed out that my component doesn't show up.
Statamic Static Cache
At first I expected the bug to be somewhere in Extism, maybe in a version that doesn't work with enhance-ssr-wasm
on my server or something. As a first fix, I included the web component in my JS bundle to at least render it on the client side. After some digging, I noticed that the component always failed to render the second time I loaded the page.
This pointed me in the right direction: Locally, I had disabled my Statamic cache, while in my production environment I'm using full measure static caching - this "means that the HTML files are loaded before it even reaches PHP". As a result, my Laravel middleware was unreachable once the page was cached.
Fortunately, this was easily fixable, as Statamic offers to include replacers for static caching. I set up Enhance as a single service, which was then used by both the middleware and the replacer. See my corresponding commit for more insight.
Potential
I started this project to visualize my workouts. I ended up creating a super flexible component in terms of compatibility:
-
activity-graph.js
: A reactive, browser-rendered web component that can be used with any frontend framework or plain HTML. -
activity-graph-element.js
: An@enhance/ssr
compatible element that can be pre-rendered in any Node.js environment. -
activity-graph-wasm.js
: Anenhance-ssr-wasm
compatible element that can be pre-rendered in any environment supported by Extism - including PHP.
The amazing thing for me is: The usage of the component is the same in every environment, using the absolute basics of HTML: tags and attributes.
<activity-graph
range-start="2024-01-01"
range-end="2024-12-31"
activity-data="2024-01-14,2024-03-15"
activity-levels="0,1"
first-day-of-week="1"
></activity-graph>.
Another thing that blows my mind is the predictability on the server side with compiled WASM files: The exact same script always produces the exact same output in any environment. It's not a similar implementation like Handlebars for JS vs. Handlebars for PHP - it's the exact same implementation. I'm just beginning to grasp what this unlocks, but for the first time I feel like there are ways to take the concept of framework and technology agnostic web components from the client to the server.
Personally, I'm so much looking forward how this will evolve โย and I would love to hear your thoughts on that, so feel free to drop a comment on Mastodon!
PS: The first quarter of the year is over and I'm still on track with my workouts. ๐ช๐ป These are programatically synced from iOS to Statamic, by the way - if you're curious how I did it, be sure to subscribe via RSS or follow me on Mastodon for my next post.