A small static page generator for Node
With HTML/JS as your output, it can be very tempting to write a single web page in just a single .html
file. HTML definitely feels like an output format, you get OK tooling support in VS Code but there's very little abstractions in the language to do anything re-usable. So, nearly all of the time it pays off to add build tooling.
The last single-page mini-site I built, shiki-twoslash's homepage, using Next.js and while that is a slick setup. It's overkill for a single page. For example, I had to post-process their output to remove a bunch of JavaScript. I wasn't using the tool for what it was made for. I've built sites big and small with Gatsby, Next.js, Jekyll, 11ty etc and they all have their place in generating static sites, and a single page with no JS ain't it. [Astro][https://astro.build] is probably the best fit.
For a project I was looking at, I needed to keep everything as vanilla as possible. So, Node, not Deno, npm not pnpm etc. Every extra dependency is a weight, and I asked myself what is an the absolute minimal approach to building a static HTML page with useful tooling?
Requirements
Things I'd need:
- IDE-level assistance
- A good templating system to allow code re-use
- Saving and seeing the changes
For "IDE-level assistance", I can use // @ts-check
to get good enough tooling support in a JS file. There's not enough value for TypeScript in basically a single file project.
A good templating system turned out to be JSX, no-one else gets as high quality tooling because no-one else has the TypeScript team supporting their templating system as a first class citizen. This introduces a problem though, your JS files need a compile step.
Finally, I need a way to save a file in my editor and see the changes. It doesn't need to be hot module reloading (there's no JS in the output anyway) but it should be fast enough.
Result
We're going to build a dev server in node, which triggers rendering some JSX files to a static HTML file on saves. To do that, we'll need some dependencies:
The folder structure will look like:
❯ tree . . ├── index.html ├── node_modules ├── package.json └── src ├── build.jsx ├── components.jsx └── server.js
With a root index.html
being the output page, and all the JS living inside the src
dir. We'll be focusing on src/server.js
.
Building the HTML
I'm writing ESM, so I can't rely on clearing the require.cache
to force the current process to re-evaluate changed JS inside Node. So, the hacky, but a wee bit slower method is to shell out each time:
src/server.jsts
import {execSync } from "child_process";import {watch ,readFileSync } from "fs"import {tmpdir } from "os";import {join } from "path";// Convert and run the 'app' codeconstbuild = () => {consttmpFile =join (tmpdir (), "tc39-template.js")// JSX -> JS, then evaluate that output in nodetry {execSync (`npm run esbuild --log-level=warning --platform=node --bundle src/build.jsx --outfile=${tmpFile }`)execSync (`node ${tmpFile }`)} catch (error ) {console .log (error )}}
Next we need to keep track of when a file changes, and trigger that function:
src/server.jsts
import {watch } from "fs"import {stdout } from "process";// FS watcher on the src dir which triggers a re-build, and then tells// any connected webpages to reload.watch ("./src", () => {stdout .write (".");build ()});
Yeah, good fs watching primitives ship with node. The watch
function in fs
is good enough for the TypeScript compiler, so it's good enough for me. If you want a better one, look at watchman which I use in the TS website.
Build Script
This will convert a JSX tree to HTML, it doesn't build it in a way which allows for using the React component APIs, but I don't want to be building JS powered experiences when HTML will do:
src/build.jsxjsx
// @ts-checkimportReactDOMServer from 'react-dom/server';import {writeFileSync } from "fs"constPage = () => <html ><head ><title >My web page</title ></head ><body ><p >Hello, world</p ></body ></html >consthtml =ReactDOMServer .renderToStaticMarkup (<Page />)writeFileSync ("./index.html",html )
This is enough to get started. You can start the server by running node src/server.js
and pressing save in the src
dir will re-build the index.html
.
Build and Refresh
This is good enough, but I want comfortable. So, I'd like the browsers to refresh when I press save. This means there needs to be a websocket connecting the browser to the dev server, and we need to send a message from the server to clients to update.
To start off, we'll switch to making a http server, while you might think to reach for express/koa/fastify - the one in Node is good enough for TypeScript, and so it's good enough for me. Let's make a server which responds with the HTML for your file:
src/server.jsjs
import {watch ,readFileSync } from "fs"import {createServer } from "http"// Starts up a HTTP server which passes along the htmlconstserver =createServer (function (req ,res ) {res .writeHead (200, {'Content-Type': 'text/html'});res .write (readFileSync ("./index.html", "utf8"));res .end ();}).listen (8080);
I looked into what the websocket support looks like in Node, and it's something I'd have had to build myself and I'm certainly not doing that. So, we can use ws
as a dependency here, I used it in Peril and it has no dependencies.
src/server.jsjs
import {createServer } from "http"import {stdout } from "process";import {Server } from 'ws';// ...// Create a websocket, and file watcher so that pressing save in the index// triggers a browser reload, letting you edit without jumping over and// refreshing.constwss = newServer ({server });console .log ("Started up dev server: http://localhost:8080")// FS watcher on the src dir which triggers a re-build, and then tells// any connected webpages to reload.watch ("./src", () => {stdout .write (".");build ()wss .clients .forEach (client =>client .send ("reload"))});
That's the server set up, it can now load up a dev server and file watcher. Changes to the ./src/*
files trigger any connected clients to refresh.
Here's ./src/server.js
in full:
js
import {execSync } from "child_process";import {watch ,readFileSync } from "fs"import {createServer } from "http"import {tmpdir } from "os";import {join } from "path";import {stdout } from "process";import {Server } from 'ws';// Convert and run the 'app' codeconstbuild = () => {// We can't edit the require cache in ESM, so this needs to run through// a new process each time.consttmpFile =join (tmpdir (), "tc39-template.js")// JSX -> JS, then evaluate that in nodetry {execSync (`yarn esbuild --log-level=warning --platform=node --bundle src/build.jsx --outfile=${tmpFile }`)execSync (`node ${tmpFile }`)} catch (error ) {console .log (error )}}// Starts up a HTTP server which builds the site each time it is requestedconstserver =createServer (function (req ,res ) {res .writeHead (200, {'Content-Type': 'text/html'});res .write (readFileSync ("./index.html", "utf8"));res .end ();}).listen (8080);// Create a websocket, and file watcher so that pressing save in the index// triggers a browser reload, letting you edit without jumping over and// refreshing.constwss = newServer ({server });console .log ("Started up dev server: http://localhost:8080")// FS watcher on the src dir which triggers a re-build, and then tells// any connected webpages to reload.watch ("./src", () => {stdout .write (".");build ()wss .clients .forEach (client =>client .send ("reload"))});// Trigger a build anywaybuild ()
Client Side
We need the index.html
to be able to connect back to the websocket dev server, to do that we can use a React component. So, make your first component in ./src/components.jsx
.
jsx
// @ts-check/** A websocket connection to the dev server which triggers a reload */export constDevWebSocket = () => {if (!process .env .SITE_DEV ) {return null}constjs = `// Listen for messages telling us to reloadconst socket = new WebSocket('ws://localhost:8080');socket.addEventListener('message', function (event) {document.location.reload()});`return <script dangerouslySetInnerHTML ={{__html :js }} />}
This component NOOPs unless the env var SITE_DEV
is set up, and it simply jams some JavaScript into the page which connects to the dev server, and on receiving any message it will trigger a reload. This needs importing in ./src/build.jsx
.
src/build.jsxjsx
// @ts-checkimportReactDOMServer from 'react-dom/server';import {writeFileSync } from "fs"import {DevWebSocket } from "./components.jsx"constPage = () => <html ><head ><title >My web page</title ><DevWebSocket /></head ><body ><p >Hello, world</p ></body ></html >consthtml =ReactDOMServer .renderToStaticMarkup (<Page />)writeFileSync ("./index.html",html )
This means running SITE_ENV="true" node src/server.js
will start up a dev server, where pressing save will render the JSX tree to HTML and reload all open web-pages. We did it with very few tools, and ideally understood how all of the pieces came together.
Moving Inline
With the system working, we can look at making the iterations a bit faster. The slowest parts by far are the two execSync
s. Let's replace these with in-process work to avoid the loading of a new process, and the node/npm boot times. The easy step is to convert the esbuild
call to the esbuild JavaScript API:
diff
+ import { buildSync } from "esbuild";- execSync(`npm run esbuild --log-level=warning --platform=node --bundle src/build.jsx --outfile=${tmpFile}`)+ const result = buildSync({ logLevel: "warning", platform: "node", bundle: true, outfile: tmpFile, entryPoints: ['src/build.jsx'] })
That should shave a bit of time off the build process, you're mostly looking at dropping the nodejs bootup for npm and then any npm run
overhead. Next we need to try run this code inline. Now, I mentioned earlier that we can't use a require cache to import the code, so that's not really doable - but a simpler answer does exist: eval
.
To safely eval the code we don't need much. esbuild, bundles and converts our JS to commonjs, which means we need a require
in scope. In ESM Node, we can create our own require
function via createRequire(import.meta.url)
which means we can convert our entire build function to:
ts
import {buildSync } from "esbuild"import {createRequire } from 'module';// Convert and run the 'app' codeconstbuild = () => {consttmpFile =join (tmpdir (), "tc39-template.js")// JSX -> JS, then evaluate that in nodeconstresult =buildSync ({logLevel : "warning",platform : "node",bundle : true,outfile :tmpFile ,entryPoints : ['src/build.jsx'] })if (!result .errors .length ) {try {constrequire =createRequire (import.meta .url );eval (readFileSync (tmpFile , "utf8"))} catch (error ) {console .log (error )}}}