Five years ago, Rich Harris created something that is revolutionizing the way I write JavaScript applications. No, it's not Svelte. Definitely not Svelte.
I'm talking about virtual modules, of course! This amazing but underrated little feature brought to life by Rollup has been a life-saver designing Fastify DX, which is built on top of Vite, which in turn is built on top of Rollup. I say underrated because I couldn't find a single blog post about it. So here's one.
Rewriting Anton Medvedev's year packages with Vite
Anton Medvedev is a brilliant developer who wrote zx, fx, expr, and many other cool things. He's also a bit of a comedian, having published all epoch dates as separate npm packages. Thanks to him, I'm able to import my birthday from npm.
I'm not even mad, that's amazing:
const birthday = require('@year/1986/03/05')
That's fun — but obviously overkill, wouldn't you say? Not only there are individual packages for each epoch year, each package has an individual file for each date. By using virtual modules, and of course a build tool that supports them like Vite, we could write something like this:
function viteDates () {
const prefix = /^\/?dates:/
return {
name: 'vite-plugin-dates',
async resolveId (id) {
const [, date] = id.split(prefix)
if (date) {
if (isNaN(Date.parse(date))) {
throw new Error('Trying to load an invalid date')
}
return id
}
},
load (id) {
const [, date] = id.split(prefix)
if (date) {
return {
code: `export default new Date(Date.parse('${date}'))`,
map: null,
}
}
},
}
}
This allows you to have imports like this:
import birthday from 'dates:1986/03/05'
document.body.innerHTML = `<p>My birthday is <b>${
new Intl.DateTimeFormat('en', { dateStyle: 'long' }).format(birthday)
}</b>.</p>`
You can probably make out what's going on just by looking at the plugin code.
First we override the default module resolution with resolveId()
, and trick
Vite into thinking those dates:
imports actually exist. Then we use load()
to actually return the contents of the virtual module.
I went ahead and published that package to npm.
Leveraging Virtual Modules in Fastify DX
This week I published the first alpha release of Fastify DX, a full stack framework I've been working on. This first release features support for React 18+, with internals based on Fastify (with fastify-vite), React Router and Valtio.
When you start a new Fastify DX project, you get this boilerplate:
├── server.js
├── client/
│ ├── index.js
│ ├── context.js
│ ├── layout.jsx
│ ├── index.html
│ └── pages/
│ ├── index.jsx
│ ├── client-only.jsx
│ ├── server-only.jsx
│ ├── streaming.jsx
│ ├── using-data.jsx
│ └── using-store.jsx
├── postcss.config.cjs
├── vite.config.js
└── package.json
I'm personally very pleased with it — this is my dream setup. A server file,
which boots Fastify and makes my client bundle available to it. Fastify DX
uses Vite just for the client side
of the application. In development mode, it boots Vite's Dev Server and Fastify
DX uses its ssrLoadModule()
function to load client/index.js
(which exposes
the client bundle to Fastify). In production, it skips booting Vite's Dev Server
and just loads the production bundle for that same file.
But what you see in the file tree above is a lie — a lot of features are
provided internally by Fastify DX. Typically, frameworks bundle their internals
as external npm packages. Take for example Remix's useLoaderData()
:
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
export const loader = () => json({})
export default function Route () {
const data = useLoaderData()
// ...
Together with getServerSideProps()
in Next.js, useAsyncData()
in Nuxt.js, the magical fetch()
in Astro, load()
in SvelteKit and useRouteData()
in SolidStart, Remix's useLoaderData()
is a way to automate universal route data prefetching.
The universal part comes from the fact these functions work seamlessly on the server, during the SSR phase (first render), and on the client, as client-side (History API based) navigation takes over. The basic pattern is this: data is prefetched on the server during SSR and provided to the client bundle as an inlined script or an automated JSON request on page load. When the user goes to the next route, client-side, new JSON requests are fired, to a server-side API automatically set up to deliver them for each route. As you can imagine, implementing this pattern cleanly is not as straightforward as one might be led to believe.
Extending data prefetching internals on these frameworks is not an easy feat. In general, all frameworks provide this functionality as a black box. Sometimes it may be possible to provide a façade to internal packages, but they are most of the time not designed to be extended like that.
Aside from staying minimal, Fastify DX's core philosophy is to be transparent and extensible as a framework. That's why virtual modules fit like a glove.
The Ejectable Virtual Modules Pattern
Fastify DX packs most of its internals as virtual modules. Here's what the starter template would look like if all internal modules were provided as part of it:
├── server.js
├── client/
│ ├── index.js
+ │ ├── base.jsx
+ │ ├── mount.js
+ │ ├── resource.js
+ │ ├── router.jsx
+ │ ├── routes.js
│ ├── context.js
│ ├── layout.jsx
│ ├── index.html
│ └── pages/
│ ├── index.jsx
│ ├── client-only.jsx
│ ├── server-only.jsx
│ ├── streaming.jsx
│ ├── using-data.jsx
│ └── using-store.jsx
├── postcss.config.cjs
├── vite.config.js
└── package.json
These files are available in fastify-dx-react/virtual
and are exposed to
the application through the /dx:
virtual import namespace. They take care of
implementing route data prefetching functionality
, among other things.
Fastify DX aims to provide an extremely simple and well documented internal implementation for those basic functionalities. The idea is that framework users should have basic needs served with the default implementation, but remain able to freely extend or modify it to for more specialized or advanced purposes.
Files imported with this prefix are picked up by Fastify DX's Vite plugin, which
serves as a virtual shadow file system. If you try to load /dx:file.js
,
first the plugin will try and resolve file.js
from your Vite project root.
If it can't find it, it'll load the one provided by Fastify DX.
The index.html
file, for instance, links to /dx:mount.js
as the client
entry point. This way, users don't have to worry about providing this piece of
boilerplate code. But if they want to customize it in any way, all they have
to do is eject it:
cp node_modules/fastify-dx-react/virtual/mount.js mount.js
And from this point forward, the local file would be used, without requiring you
to change any of the /dx:
imports. In the future, Fastify DX will provide
a CLI with a convenience eject
command to perform the simple operation above.
⁂
I'd love to see this pattern become mainstream. It's a clean, elegant and transparent solution to providing internal functionality without the boilerplate weight and without compromising extensibility.
Data Prefetching in Fastify DX
Fastify DX's data prefetching system is very similar to that of every other framework mentioned. You can export a function that becomes responsible for returning data to the route, and you can use a hook to access it:
import { useRouteContext } from '/dx:router.jsx'
export const getData = () => ({})
export default function Route () {
const { data } = useRouteContext()
// ...
}
What's different about it is how it is implemented. Notice
useRouteContext()
is coming from /dx:router.jsx
. Together with /dx:resource.js
and
/dx:routes.js
, it provides the basis for isomorphic data prefetching in
Fastify DX. To extend or modify any of the behavior, you can simply
eject those files into your project.
There's nothing fancy about the implementation either. Just React Router (also part of Remix's core) powered by context providers managing the context and data loading for each route, leveraging the server-provided hydration on first render.
That's the client-side side part of it. The server-side part is equally open
and extensible. Fastify DX for React
itself is a fastify-vite renderer adapter. If you look at
fastify-dx-react/index.js
, you'll see it exports a number of functions:
prepareClient()
createHtmlFunction()
createRenderFunction()
createRoutes()
These are all fastify-vite configuration functions.
The createRoutes()
function is responsible for picking up the routes provided
by the root client module (client/index.js
), where it gains access to the
getData()
exports for each route. Fastify preHandler
hooks executing getData()
are set for each route, so they're ran in advance before any SSR
happens. It also uses them to register JSON endpoints, so they can be executed
from the client as well.
You can easily modify or extend all this behavior by providing your own configuration functions, e.g.:
// ...
import FastifyVite from 'fastify-vite'
import FastifyDXReact from 'fastify-dx-react'
import { customCreateRoutes } from './custom-routing.js'
// ...
await server.register(FastifyVite, {
root: import.meta.url,
renderer: {
...FastifyDXReact,
createRoutes: customCreateRoutes,
},
})
I hope this article got you interested enough to try out Fastify DX. Right now, only React is supported but Vue, Solid and Svelte support following exactly the same API are coming soon. If you try it, make sure to file an issue for any problem you run into or anything that needs clarification.