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:

  1. Remove the deno.lock if you have one
  2. Put --reload to your start script.
  3. Clean the cache with deno cache --reload dev.ts and for the main.ts
  4. 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.