Homebrewing a better Static Site Generator with Bun and React

React just for static content, you ask? Yes indeed! Follow along for more atrocities.

An "escalator" made of stationary marble stairs"
"Using React to make a static website", as tweeted by @flaviocopes

Well, that was fast. I knew my previous solution wasn't going to be the best option in the long run. It gave me a development environment with fast and reliable templates that could sorta be composed with one another, interfacing with a decent configuration system. It wasn't quite capable of the full modularity that a modern web developer needs, though. I've already got my heart set on a better alternative.

I think I was so focused on what my idea of a static site generator looks like, I missed the solution that was under my nose the whole time. I've been writing React professionally for a few years now, so why not use that? It gives me:

  • Templating, via JSX
  • A robust component-based architecture - the JS runtime takes care of composition, without having to use my janky half-baked composition scheme
  • Well-defined component configuration, with type safety via TypeScript!

Of course, I'm not the first person to have this idea. Gatsby is a fairly advanced content management platform with static content generation capabilities that also lets you use React in a similar manner. react-static is a "progressive static-site generator", meaning it is designed to pre-render all of the user's React content to static HTML, and then hydrate it into a full React application at load time similarly to other server-side rendering applications. I'd like to retvrn to tradition though, and keep everything fully static for the same reasons I outlined in my last post. I'm not particularly interested in the complexity that Gatsby offers, either, and the fun of this whole process is to hack out my own solution. So we do it live.

Getting the ball rolling

The react-dom package provides server-side rendering, which is exactly what we want to turn our React components into servable HTML. This is actually new territory for me, because my previous React work has involved bundled applications served by something written in a different language, in which bundle size and latency weren't yet big enough concerns. When I was first playing around with this concept, I tried implementing it in TypeScript and running it with ts-node, but I soon learned that it's annoyingly slow for development. This inspired me to finally try out Bun, the shiny new JS environment.

Bun is the new kid on the block, and it's still young, but it has a lot of neat built-in creature comforts: a very fast TypeScript interpreter, an easy-to-use web server that I'll demonstrate in a bit, and crucially, hot reloading with the --hot flag. When I was first trying this project out in Node, I tried to cook up my own hot-reloading scheme that quickly fell apart, so I was delighted to see that this was already taken care of for me - another big reason to make the switch.

Some of the other features I was planning to add to this don't yet seem to work in Bun - for example, I wanted Chokidar and Sass for built-in CSS compiling, but had some struggles getting everything to play along - but those all have workarounds. The most important thing was react-dom, which fortunately worked fine once I figured it out. There's a whole mess of legacy streams from the Node days, each with their own nuances and compatibility issues, but the modern standard "Web Streams" API is supported natively by Bun. So, we have all of the pieces, now we just have to put them together.

Alright, let's do it!

We will have a similar content structure as last time, with the same layouts. Eventually we will need to be able to build the whole site for deployment, but before that, we need a suitable dev server.

Path handling

Each content page will correspond to a single .tsx file, which means we need path handling. There are also some funky behaviors we have to contend with, like dealing with absolute HTTP request URIs versus local relative paths, so I've come up with this class below to deal with it.

export class Path { 

    dirs : string[] 
    name : string 
    ext  : string 
    constructor(dirs: string[], name: string, ext: string) { 
        this.dirs   = dirs
        this.name   = name
        this.ext    = ext
    }

    static Parse(path: string) : Path { 
        const match = path.match(/^\/*(.*\/)?([^.\n]+)?(\.[\w\.]+)?$/)
        if(! match) throw "Could not parse path"

        return new Path(
            match[1]?.split('/') ?? [], 
            match[2] ?? '', 
            match[3] ?? '',
        )
    }

    isEmpty() : boolean { return this.dirs.length == 0 && this.name == '' && this.ext == '' }

    withExtension(ext: string) : Path { 
        return new Path(this.dirs, this.name, ext) 
    }

    relativeTo(...dirs : string[]) : Path { 
        return new Path( dirs.concat(this.dirs), this.name, this.ext )
    }


    // ...
}

This class represents the parts of a file path, similarly to any good path handling library, but tailored to our needs. There's a heck of a lot going on in that regex, so let's break it down. If you're not comfortable with regexes, I encourage you to try it out on a regex tester like this one!

  • The opening ^\/* matches any number of forward slashes at the beginning of the string, discarding them since we will be juggling between absolute and relative paths
  • The first capture group (.*\/)? matches any characters leading up to a forward slash. The greedy quantifier * is called that because it will do so as many times as it can, even if there are other slashes in the way, so it matches all the way up to the last slash in the path. We split it by "/" to obtain our dirs component.
  • The second capture group ([^.\n]+)? tries to find the largest string of characters up until a dot or a newline. This is our name component.
  • The third capture group (\.[\w\.]+)?$ tries to match a dot, then any other word characters all the way to the end of the line. This is our ext component.

This allows us to gracefully change parts of the path using the withExtension function. Since our class splits the string evenly, without discarding anything (except for leading slashes), to render the path back into a string we can just concatenate the components together. For local paths, we can go ahead and resolve them into absolute paths here too - more on why we do that later.

// Up top:
import { resolve as resolvePath } from 'path' 

const CWD = process.cwd()


// In class `Path`:
// [...]

private relative() : string { return this.dirs.join('/') + '/' + this.name + this.ext }
getURI() : string { return '/' + this.relative() }
getLocalAbsolute() : string { return resolvePath(CWD, this.relative()) }
getRelativeDir() : string { return this.dirs.join('/') }

// [...]

The last piece is what I'll call splicing, which checks to see if a path is located within a given parent directory, and returns the Path instance relative to that directory. This is a pretty obscure operation, but it makes it easier to reason about serving files later on. It looks like this:

// In class `Path`:
splice(...prefix: string[]) : Path | null { 
    for(let j = 0; j < prefix.length; j++) { 
        if(prefix[j] != this.dirs[j]) return null
    }

    return new Path(this.dirs.slice(prefix.length), this.name, this.ext) 
}

Rendering Content

So, we've got path handling out of the way, now we need to actually be able to serve content. Serving static files is easy enough, but what about our React components? What we want is a way to load the TypeScript file dynamically by file path, and somehow squeeze a React.ReactElement out of it so we can pass it on to the server-side renderer.

Fortunately, JavaScript has long had a way to do this - the require function. It completely negates the static analysis benefits provided by the newer import keyword, and is therefore an abomination, but since Bun aims to be a drop-in Node replacement it's stuck here. We can use that to our advantage! The only catch is that the argument to the require function is resolved relative to the file containing the function call, not the current working directory, which is why we have to resolve everything to absolute paths like I mentioned earlier.

We want a uniform interface to export default from all of our TSX content files; we'll call it Renderable. It contains a render function returning a React.ReactElement, which we can convert to a ReadableStream via react-dom/server to actually turn it into HTML.

When we load a Renderable, we'll want metadata associated with it (namely its original path), so that we know where the rendered file goes. For that, we have a wrapper structure called LoadedContent. It's generic in order to allow for functionality that I use to handle blog posts, which is outside the scope of this post; those are just subclasses of Renderable, and they can otherwise be ignored for now.

import * as ReactDOMServer from 'react-dom/server'

export class Renderable { 
    protected body : React.ReactElement
    constructor(body: React.ReactElement) { 
        this.body = body 
    }

    // This can be overridden by subclasses!
    render() : React.ReactElement { 
        return this.body
    }

    async getReadableStream() : Promise<ReadableStream> { 
        return ReactDOMServer.renderToReadableStream(this.render())
    }
}


export interface LoadedContent<T extends Renderable> { 
    renderable: T, 
    destPath: Path, 
}

Resolving requests

It's almost time for the good stuff - we just need to turn request URIs into server output. Bun's native server implementation can serve FileBlobs for files and ReadableStreams that we can get from our React Renderables, so let's make a quick function. Assuming the request URI has been converted to an instance of our Path class, we have this:

export type Resolved = 
    { $: 'FILE', blob: FileBlob } | 
    { $: 'HTML', renderable: Renderable }


export async function fromRequestURI(uri: Path) : Promise<Resolved> { 

    function serveFile(srcPath: string) : Resolved { 
        return { $: "FILE", blob: Bun.file(srcPath) }
    }

    async function serveRenderable(srcPath: string) : Promise<Resolved> { 
        return { $: 'HTML', renderable: (require(srcPath).default as Renderable) };
    }
 
    if(uri.isEmpty()) return serveRenderable(resolvePath(CWD, 'src/content/index.tsx'));

    const asset = uri.splice('assets')?.relativeTo('assets').getLocalAbsolute()
    if(asset) return serveFile(asset)


    if(uri.ext == '.htm') return serveRenderable( uri.withExtension('.tsx').relativeTo('src', 'content').getLocalAbsolute() )

    return serveFile(uri.relativeTo('dist').getLocalAbsolute())
}

TypeScript doesn't have proper sum types, but there are a few half-decent alternatives for our return type here. An untagged union is fine for some cases but we need to do some logic branching later on based on what type is returned in the server itself, and I'm not keen on relying on the instanceof operator because the prototype chain of these objects doesn't seem to be documented. Many folks will just use a union of different structures without a tag, but I like having the $ tag here; it's very explicit, can easily be switched over, and it can have any number of properties.

In our function, we have two helpers. serveFile simply wraps Bun's native Bun.file function. serveRenderable loads the TypeScript module at the provided path, and naïvely assumes it's an instance of Renderable.

Our rules from there are simple:

  1. If the URI is empty (just "/"), we serve a hard-coded index URI.
  2. If the URI is in the "/assets" directory, we find the corresponding file in the real assets directory, and serve that. It may seem silly and redundant to splice the Path only to resolve it again relative to the same dirname, but my experience has taught me that it's best to be explicit here in case something changes down the road.
  3. If the URI has a ".htm" extension, convert the path to a ".tsx" path, and serve that module.
  4. Finally, attempt to statically serve anything that happens to be in the "dist" directory. This is the final build output, and is also where my compiled CSS goes, which happens completely independently of this project.

Actually serving the dang thing

Finally! Everything covered above lives in a file called Resolver.ts, because I never came up with a better name for it. The actual code for the dev server is below, and it's super simple, just using Bun's standard server interface:

import URL from 'url' 
import { fromRequestURI, Path } from './src/ssg/Resolver'

export default { 
    port: 3000, 
    async fetch(req: Request) : Promise<Response> { 
        let path = URL.parse(req.url).path
        if(! path) { 
            console.error("No path!") // I'm not even sure how this would happen
            return new Response(null, { status: 500 })
        }
        const resolved = await fromRequestURI(Path.Parse(path))

        switch(resolved.$) { 
            case 'HTML': return new Response(await resolved.renderable.getReadableStream(), { headers: { contentType: 'text/html' } })
            case 'FILE': return new Response(resolved.blob, { headers: { contentType: resolved.blob.type } })
        }
    }
}

When running this with hot reloading (bun run --hot server.ts), Bun will automatically reload any TypeScript modules that change, so this does everything we need it to! It's super fast, and we don't even have to worry about our total lack of error handling in the previous sections, because Bun will just gracefully catch anything that we don't (namely file-not-found errors) and display a decent error page for us.

Okay, but this is supposed to be static, right?

Up to this point, we've created a way to load and render standalone React components as complete HTML pages, and set up a dev server that can do this dynamically for us, along with the other static content like assets and stylesheets that makes web sites work. That's great for development, but this is supposed to be a static site! So, now that we're all done, and I've finished writing this post, it's time to build the site for deployment.

The only missing piece is a function to load all of our renderable content. (The reason why this is split in two is again because of the Blog stuff that I'm ignoring for now.) I use Glob here, because I'm lazy:

import { glob } from 'glob'

function getContent<T extends Renderable>(src: string) : LoadedContent<T>[] { 
    return glob.sync(src).map(path => ({
        renderable: require( resolvePath(CWD, path) ).default as T, 
        destPath: Path.Parse(path).splice('src', 'content')!.withExtension('.htm'),
    }))
}

export function getAllContent() : LoadedContent<Renderable>[] { 
    return getContent<Renderable>("src/content/**/*.tsx")
}

Now that we've got all of that in place, building the site for deployment is actually fairly simple; this code all lives in a script called build.tsx. I unfortunately didn't find an easy way to pipe a web stream to a Bun file, but doing it manually is easy enough:

import { getAllContent } from './src/ssg/Resolver'

import FS from 'fs' 

for(const content of getAllContent()) { 
    const destPath = content.destPath.relativeTo('dist')

    FS.mkdirSync(destPath.getRelativeDir(), { recursive: true })
    const writer = Bun.file(destPath.getLocalAbsolute()).writer()

    const reader = (await content.renderable.getReadableStream()).getReader()

    writer.start()
    for(;;){ 
        let read = await reader.read()
        if(read.done) break 
        writer.write(read.value)
    }
    writer.end()
}

All Done!

I did cut a few corners for this project. I stopped benchmarking and paid less attention to performance; I don't think that will come back to bite me any time soon, but I'm sure the gods will smack me down for my hubris soon enough. Additionally, I'm kinda playing fast and loose with a few React best practices: for instance, I'm not lazily-evaluating React.ReactElement like you're supposed to by wrapping everything in components (let's just call it "caching" 😉). These will only be a problem if I do decide to make this web site dynamic, so I'll save that problem for future me, who will surely curse my name for it.

Thanks for reading! I'm happy with the results, and it's hard to imagine living without it now. I wonder how long it will last this time!