A Laboratory Powered by Deno, Fresh, and Tailwind
Some time ago, I wanted to build a tool to measure CSS parsing performance, and expected it to be the first of many experiments. I’d have added them to A Place For My Head with WebC and is-land, but I’m stuck on Eleventy v1.0.2 until modules are natively supported. I didn’t want to run the Vite bundler alongside Eleventy with all its concomitant complexity just to handle JavaScript. I decided to create a new space instead. (My thanks to uncenter for recommending the name ‘lab’ instead of ‘playground’.)
Now, I could have used Eleventy there too, but if I was going to go to the trouble of making something entirely new, I wanted a server-first approach. The natural choice would be my favourite, SvelteKit, with which I recently started a work project I’m enjoying. Meanwhile, I’ve been looking for an opportunity to get some experience with the Deno JavaScript runtime, so I thought I’d use the two together.
A Fresh approach
Unfortunately, SvelteKit was incompatible with Deno, primarily due to Vite. After much agonizing, I concluded I should replace it with the new Fresh framework, to avoid dropping Deno, even though I dislike the React component model it uses. I do admire Fresh’s philosophy of not serving JavaScript unless required. (On a minor note, I’m not thrilled that it enforces the absence of trailing slashes on URLs.)
I built a route to generate the CSS files I needed. Neither Mocha nor Vitest works correctly with Deno, so I looked up the documentation on testing and wrote some unit tests. I haven’t checked whether UI testing with Playwright is possible.
Fresh generates a template that includes Twind (Tailwind as a standalone
tool). I’ve been skeptical of the utility classes pattern for a long time; now
I could try it myself. I can honestly say I find it much slower, more limiting, and more tedious
than the now-ancient BEM pattern. In addition, I initially
couldn’t seem to customize the library at all, or even tell whether the defaults were active: the
text-emerald-700
class shown in the Tailwind
documentation had no effect. After seeing something
relevant on Discord I restored the apparently ineffectual code I’d removed and found that
twind.config.ts did work. Ironically, however—considering the origin of the project—I can’t see
how to turn off CSS minification.
I thought Fresh does something special with forms when hydrated, but no. To allow regular form
submission, I put each form in an
island and only hide
the submit button if IS_BROWSER
is
true (in which case
my event handlers are responsible for submitting the form). The second experiment worked
perfectly.
Impressions from baby’s first Deno project
Deno’s npm support confused me. import x from "npm:y"
failed in the general case, but
importing from @preact/core
worked. I eventually noticed Fresh had created an import map to enable
the latter. I’m now using Skypack or esm.sh URLs for npm modules. It’s unclear what the right
technique is.
I told GitLab CI to cache the .deno directory, told Deno to use that directory, and ended up with
deno lint checking the entire dependency tree. It transpires that directories can’t be excluded
globally in deno.json. Putting identical exclude
entries under lint
and test
fixed it.
deno fmt disagrees with Prettier, which I suppose is because it uses the dprint formatter. I’m sticking to Prettier for consistency with other projects. Apart from that, I love the fast, tool-free Deno experience. Running my tests in CI the first time, with zero caching, took 16 seconds. npm would still have been resolving the dependency tree.
I made Emacs use the extremely fast deno run -A npm:prettier instead of npx prettier too (with a bit of help from b-fuze when it came to the correct invocations). I also set up colour conversion in Emacs using Deno, but that’s a story for another time.
Refining the build
The first version of the Dockerfile was quite straightforward. Some time after setting it up and deploying it in my Kubernetes cluster, I was unexpectedly greeted by a mystifying build error on an unrelated commit:
Output#8 5.192 error: The source code is invalid, as it does not match the expected hash in the lock file. #8 5.192 Specifier: https://esm.sh/*preact-render-to-string@5.2.6 #8 5.192 Lock file: /app/deno.lock
I hadn’t touched the Preact dependency. Experimentation revealed the build worked locally if I removed deno.lock and if I ran deno test after removing it, but not if I only ran deno task start. On the Deno Discord, all I found at first was a recommendation to delete deno.lock, which I had already done. Searching for the error itself turned up one message, linking to an outwardly unrelated issue with a promising suggestion:
- Remove the deno.lock if you have one
- Put
--reload
to yourstart
script.- Clean the cache with
deno cache --reload dev.ts
and for themain.ts
- Run, then remove the
--reload
These instructions worked like a charm. I committed the updated files and the issue went away.
I couldn’t tell how to use the barely-documented distroless variant of the official Deno
image. It has a different user (nonroot
instead of
deno
) and no shell, obviously, so I can’t run the normal build steps and don’t know what to copy
in a multi-stage build. On top of that, Fresh needs to build and cache things at runtime. I can’t
give it a read-only filesystem. It even requires a privileged user.
b-fuze directed me to the DENO_DIR
variable,
but it was already set to /deno-dir
in the base image. The culprit was in fact /home/deno
. I
made the image use the deno
user and mounted an ephemeral volume at runtime over /home/deno
.
That seemed to solve the problem. I still couldn’t use a read-only filesystem, though, since Fresh
writes to DENO_DIR
at runtime.
Later improvements involved copying /deno-dir
from the build stage to make use of the cached
dependencies and copying a /home/deno
created in the build stage to /home/nonroot
. With this
approach,
I can continue to mount an ephemeral volume over the home directory at runtime as well as drop all
privileges. The dive tool now reports 99% efficiency and
everything is tolerably well isolated.
I wanted to use deno compile to create a standalone binary, just for fun. Sadly, it gave me a type error. Upgrading from Fresh v1.1.5 to v1.3.1 and Deno v1.33 to v1.36 made it succeed, but no pages would load:
OutputAn error occurred during route handling or page rendering. ReferenceError: React is not defined at Object.App (file:///D:/Media/My/www/lab/routes/_app.tsx:5:5) at https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:1830 at T (https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:1857) at T (https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:1933) at T (https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:1933) at z (https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:316) at renderInner (https://deno.land/x/fresh@1.3.1/src/server/render.ts:125:20) at renderSync (https://deno.land/x/fresh@1.3.1/src/server/render.ts:147:13) at https://deno.land/x/fresh@1.3.1/src/server/render.ts:197:46 at Object.DEFAULT_RENDER_FN [as renderFn] (https://deno.land/x/fresh@1.3.1/src/server/render.ts:10:5) ReferenceError: React is not defined at Object.App (file:///D:/Media/My/www/lab/routes/_app.tsx:5:5) at https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:1830 at T (https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:1857) at T (https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:1933) at T (https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:1933) at z (https://esm.sh/v130/preact-render-to-string@5.2.6/X-ZS8q/denonext/preact-render-to-string.mjs:12:316) at renderInner (https://deno.land/x/fresh@1.3.1/src/server/render.ts:125:20) at renderSync (https://deno.land/x/fresh@1.3.1/src/server/render.ts:147:13) at https://deno.land/x/fresh@1.3.1/src/server/render.ts:197:46 at Object.DEFAULT_RENDER_FN [as renderFn] (https://deno.land/x/fresh@1.3.1/src/server/render.ts:10:5)
A good time so far
Setting aside the architecture and the runtime compilation, the only major limitations of Fresh I’ve encountered so far are related to the lack of a layout mechanism, which was rectified in v1.4.0, and the absence of useful context in some areas, which was rectified in v1.2.0 (superseding my own simplistic attempt). I intend to update my project soon.
Other than that, the experience has been overwhelmingly positive. I can’t repeat this enough: Deno is fast. I’m fairly used to the slow React feedback loop. SvelteKit is much quicker, but never instantaneous. Using Fresh, going from merge request to new Docker image takes under a minute with tests. This enables shockingly quick and easy iteration on what are ultimately lightweight, progressively enhanced tools with absolutely no fat on them.