Building a builder: the core

Oleksii Koshkin
13 min readJun 24, 2024

--

an indie frontend builder:

Creds: Copilot

TL;DR;

This is a longread, so please make yourself comfortable. “Builder” here means “bundler”, so don’t expect anything too exciting. The target is framework-less small frontend projects built on JS/TS/CSS/SASS/HTML.

Three approaches are described here, which are gradually being improved:

  1. Very simple basic builder. https://github.com/lexey111/insane-nano-core
  2. More advanced one, with SASS, Lint and Typescript. https://github.com/lexey111/insane-nano-advanced

    And a version with Tailwind CSS: https://github.com/lexey111/insane-nano-tailwind
  3. The most advanced in a separate article. (GitHub, Demo).

The first two — or three — are usable, but overly simplistic. The latter is more or less professional despite still being an indie-buildie.

I just want to invent my own wheel, a triangular wheel, and no one can stop me in my run down the hill.

Introduction

It all started when I got the idea to divert from the usual and experiment with some third-party libraries on frontend. My day job is pretty boring, albeit challenging: I design bright and cool architectures, and then watch them get destroyed by real life… I mean, how SDLC gradually adds fixes, patches, compromises, technical debt, and that sort of thing.

Calm down and follow the industry standards? Phew. Pets and PoCs are the only escape. Go crazy!

Goal

I need a “framework” for building marginal frontend applications. The quotes are here because I want to make it as lightweight as possible, but not lighter, and I want some modern convenience stuff. Sometimes I don’t understand why a single page with a couple of forms and a few controls requires over 100 KB of code. Compressed code.

Technical frame

  • The bottom line is that I need to have a set of HTML, CSS and JS files, like a good old static site. It doesn’t have to be a SPA, just a set of pages.
  • Quick build. It must be fast.
  • Comfortable development. Background rebuilds, source maps, linting, bundling.
  • No frameworks. The basic idea is to have a container page with something crazy inside: Web Components, pure JS, WASM — not mainstream frontend.

I will use that to work outside frameworks like React, Angular, Vue or Svelte. They have their own recipes, lots of them; they’re good enough.

So, ultra-simple HTML/CSS/JS bundler with embedded dev-server. Let’s try.

Approach zero: nothing builder

Well, let’s just write HTML, CSS, JS files with optional modern import modules and serve them with any live-server. It’s too boring, despite it works. But I’m used to separate debug and prod builds, and small but nice conveniences like SASS or Typescript. Or simple importing npm package from dependencies — it can be done, of course, but in a clumsy way… but it’s got the spirit of the ’90s. A bit too old school.

Sure, it works, but there’s nothing to write about it, so it’s useless.

Approach one: core ESBuild builder

I will use ESBuild (https://esbuild.github.io/) as a builder tool engine. In scope of this approach I will implement a minimum minimorum: just HTML processing and JS processing. The CSS should be left as it is.

I will not describe the full process of creating a new project with a npm init and package.json, just the result:

{
"name": "insane-nano-core",
"version": "1.0.0",
"main": "src/main.js",
"type": "module",
"scripts": {
"build": "bun rimraf ./dist && bun esbuild.js",
"start": "bun esbuild-serve.js"
},
"devDependencies": {
"@chialab/esbuild-plugin-html": "^0.18.2",
"esbuild": "^0.21.5",
"rimraf": "^5.0.7"
},
"dependencies": {
}
}

Here I’m using Bun instead of NPM, just for fun.

To install it, you need — according to official documentation — run

curl -fsSL https://bun.sh/install | bash

and, then, to install dependencies –

bun i

If you prefer NPM, just replace “bun” with

"scripts": {
"build": "rimraf ./dist && node esbuild.js",
"start": "node esbuild-serve.js"
},

and then use npm i/ npm run build / npm start instead of bun i , bun run build and bun start

Let’s explain why this particular set of dependencies is used.

RimRaf: you can notice I’m using it in production build. It removes dist folder to make a clean build.

esbuild-plugin-html: of course, I can generate HTML by myself. But I want to have hashing here, so instead of

<link href="css/styles.css" rel="stylesheet"/>
<script src="js/main.js"></script>

I want to have

<link href="css/styles-WRVVTBDH.css" rel="stylesheet">
<script src="js/main-TCNFVXBI.js" type="application/javascript"></script>

and don’t care of caching. The plugin does all the work; also it keeps folders and files ordered:

├─── dist
├─── assets
├─── css
├─── js

Moreover, it can minify HTML (though we need to install htmlnano package) and takes care of manifest.json and favicon. I just put into HTML

<head>
<link href="assets/favicon.png"
rel="shortcut icon"
type="image/png"/>

<link href="assets/manifest.json"
rel="manifest"/>
</head>

run build and

<head>
<meta charset="utf-8">
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<title>Insane core</title>
<link rel="icon" sizes="16x16" href="assets/favicon-16x16-238fe475.png">
<link rel="icon" sizes="32x32" href="assets/favicon-32x32-c060a3ea.png">
<link rel="icon" sizes="48x48" href="assets/favicon-48x48-3bcae251.png">
<link rel="shortcut icon" href="assets/favicon-196x196-df7fe015.png">
<link rel="icon" sizes="196x196" href="assets/favicon-196x196-df7fe015.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon-331e462e.png">
<link rel="apple-touch-icon" sizes="167x167" href="assets/apple-touch-icon-ipad-35647069.png">
<link href="assets/manifest-1c382ff8.json" rel="manifest">
<link href="css/styles-RRJSZFON.css" rel="stylesheet">
</head>

– it generates favicons for major browsers. It really does all the work for me. Love it.

It’s certainly a shame that it’s not customisable enough, especially in terms of dealing with more complex folder structures. One-to-one matching isn’t always convenient, but okay. It’s an indie and free open source, in the end.

Well, here we have just 3 development dependencies and –

$ du -hs ./node_modules
25M ./node_modules

Wow, just 25Mb!

Windows 95 installation size

What a great leap technology has made. Unbelievable.

Code

What we have in build scripts?

// esbuild.js
import esbuild from 'esbuild'
import htmlPlugin from '@chialab/esbuild-plugin-html'

esbuild
.build({
entryPoints: [
'src/index.html',
'src/dashboard.html'
],
assetNames: 'assets/[name]-[hash]',
chunkNames: '[ext]/[name]-[hash]',
outdir: 'dist',
metafile: true,
bundle: true,
minify: true,
minifyWhitespace: true,
minifySyntax: true,
minifyIdentifiers: true,
sourcemap: true,
plugins: [htmlPlugin()],
})
.then(() => console.log('✅ Build complete!'))
.catch(() => process.exit(1))

As simple as it could be. Everything could be passed through command line options in package.json but I don’t like that approach. Eventually you’ll have to extract it, that’s always the case.

Here we have a build warning “Unable to load “htmlnano” module for HTML minification”, but let’s leave it at that. In any case, the size of the HTML is usually not the biggest problem.

In the case of a serve (dev-server), things will be a bit more complicated:

// esbuild-serve.js
import esbuild from 'esbuild'
import htmlPlugin from '@chialab/esbuild-plugin-html'

const ctx = await esbuild.context({
logLevel: 'debug',
entryPoints: [
'src/index.html',
'src/dashboard.html'
],
assetNames: 'assets/[name]-[hash]',
chunkNames: '[ext]/[name]-[hash]',
outdir: 'dist',
bundle: true,
banner: {
js: 'new EventSource("/esbuild").addEventListener(' +
'"change", () => location.reload());'
},
plugins: [htmlPlugin()],
})

console.log('✅ Build complete... running watch.\n');

await ctx.watch();

await ctx.serve({
servedir: 'dist',
port: 3000,
});

Pretty straightforward, but it’s worth mentioning a couple of points.

banner we need to auto-refresh the page on updating the source code (JS, CSS, HTML). It establishes connection between watch process and browsers, and reloads the page on update.

There are a few minor caveats here.

First, it does not cleanup dist folders between builds:

❯ ls -1 ./dist/js
main-3JYHKMZT.js
main-ACVSVFC3.js
main-C2PZKL4P.js
main-GIDBJGA5.js
main-REPXXYTI.js
main-YHTXZENK.js
main-YJL4LA4I.js

It’s crappy, but it’s ok. This is a dev build, in prod mode all the garbage will be removed. There is a plugin, esbuild-plugin-clean, which does the same in runtime, but it isn’t very compatible with esbuild-plugin-html (perhaps because the way how it uses metafile/build info, perhaps because of debouncing and stale builds on multiple requests — it is mentioned below, in the “Links do not work” section). It removes some of files generated by esbuild-plugin-html so let’s just leave it at that.

Second, and the most grave, the ESBuild watching process is very fragile. It works until you make the first error in JS, and then hangs — you need manually stop with Ctrl-C and restart build. Sometimes it recovers a couple times, sometimes it doesn’t. It just displays the error and stops.

It seems that the previous version of ESBuild with separate watch and serve modes was more stable, at least for configurations with multiple entry points. There I would just use an external web server and that’s it; here I have not enough control.

There has been a lot of discussion about this, but for technical reasons (“consistent incremental rebuild”) it seems to be “won’t fix” forever. There are some partial solutions, but they are not suitable for multiple entry points and our plugins configuration. It’s a shame, but the ESBuild author is pretty sure it’s not the business of ESBuild, and I agree.

Ok, I’ll restart it, it’ll take a split second. Not a big deal for a just 25 MB application.

That’s it? No.

Let’s create a couple of pages mentioned in configs and build them:

Run bun start, open “localhost:3000”…

Well, it works? no.

Wow, cool, it works!

Until we click the link to Dashboard… nothing happened. Why? Let’s open the Dashboard page directly…

Dashboard is here and served.

And it is here. But the link also does not work! WTF?!

Ok, it’s all about this one-liner in esbuild-serve.js:

banner: {
js: 'new EventSource("/esbuild").addEventListener(' +
'"change", () => location.reload());'
},

It adds a <script> with the content to each page rendered, and the scripts listens to change build event and reloads the page. It could be improved, but it won’t help.

And the navigation event is also handled as a reason to change. It triggers a rebuild, rebuild takes 2ms, emits event, reloads the page. This looks like a bug but most likely it is not. This is not an ESBuild problem. It’s mine.

Let’s add some debouncing here:

banner: {
js: 'new EventSource("/esbuild").addEventListener(' +
'"change", () => setTimeout(() => location.reload(), 1000));'
},

And now it works, and also adds some reasonable delay to the page auto-refresh.

Final thought and links

That’s it for the stage. What we have in front of us is a builder with production and development modes, capable of handling simple HTML+JS+CSS pages, automatically process assets, favicons.

Suitable for single-page applications where there is more CSS/HTML than JS, or vice versa, where there is a lot of JS but more for calculations than form processing.

Example of non-framework app

Like this one, 3D Tetris and WebGL. Or for some landing. Or PoC. Or admin page. Or such things. Whatever simple.

GitHub repository: https://github.com/lexey111/insane-nano-core

Live page: https://lexey111.github.io/insane-nano-core/ — of course, it makes almost no sense, but it demonstrates how quickly and easily you can go from source code to web page.

You may notice the use of this practice: GitHub hosted pages. Just add to package.json “scripts” and run:

"deploy": "bun run build && git subtree push --prefix dist origin gh-pages"

Note: actually you don’t even need to go into the repository settings and allow publishing. Just execute bun run deploy, wait for a minute, and that’s it. GitHub is smart enough to understand your intentions.

Approach two: a bit of advanced features

Let’s start adding comfort. First, we have an annoyong warning on build, nothing serious but:

Build warning

so, execute:

bun i -D htmlnano
bun i -D svgo

The second line will be prompted by HTMLNano.

Add to esbuild.js :

        plugins: [
htmlPlugin({
minifyOptions: {
collapseWhitespace: 'all',
deduplicateAttributeValues: true,
removeComments: 'safe'
}
})
],

Great, no more warnings, the HTML is now compressed. 1241 bytes instead of 1360, 9%, wow. Nice. It ain’t much, but it’s honest work.

It costs us…

❯ du -hs ./node_modules
48M ./node_modules

…only 23 extra megabytes, phew. Nothing to discuss.

Adding a Linter

Well, now I want to have Typescript and Linter in the project.

In all seriousness, Typescript makes a lot of sense for this kind of applications. This builder is built to serve non-mainstream and non-framework applications, which usually means tonnes of hand-crafted code, carefully mixed and of course, difficult to interact with. Often with third-party libraries. This means that type safety and contracts can save days of debugging. That’s exactly the job for TS.

Ok,

bun i -D typescript
bun i -D eslint
bun i -D globals
bun i -D @typescript-eslint/parser
bun i -D @stylistic/eslint-plugin-ts
bun i -D @stylistic/eslint-plugin-js
bun i -D @types/bun
❯ du -hs ./node_modules
92M ./node_modules

+44M, but ok, it’s Typescript: type safety, code quality, best practices, style guides, continuous improvement, maintainability, sustainability, team values… sorry, got carried away.

Add tsconfig.json (to taste) and eslint.config.json with controversial settings:

import globals from 'globals'
import stylisticTs from '@stylistic/eslint-plugin-ts'
import stylisticJs from '@stylistic/eslint-plugin-js'
import parserTs from '@typescript-eslint/parser'

export default [
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
},
parser: parserTs,
}
},
{
files: ['src/**/*.js', 'src/**/*.ts', './**/*.js'],
ignores: [
'dist/**/*'
],
plugins: {
'@stylistic/js': stylisticJs,
'@stylistic/ts': stylisticTs
},
rules: {
'@stylistic/js/indent': ['error', 4],
'@stylistic/js/quotes': ['error', 'single'],
'@stylistic/js/no-extra-semi': 'error',
'@stylistic/js/semi': ['error', 'never'],
}
},
]

and to “scripts” of package.json to be able to run lint and autofix:

  "scripts": {
"cleanup": "bun rimraf ./dist",
// ...
"lint": "eslint", // <--- these two lines
"lint-fix": "eslint --fix"
},

Immediately eslint of IDE or running bun run lint shows us a couple of violations in our JS files:

❯ bun run lint
$ eslint

/src/js/dashboard.js
1:13 error Strings must use singlequote @stylistic/js/quotes
1:30 error Extra semicolon @stylistic/js/semi

/src/js/main.js
1:13 error Strings must use singlequote @stylistic/js/quotes
1:25 error Extra semicolon @stylistic/js/semi

And we’ll fix them with bun run lint-fix

Note: I use the WebStorm IDE, which does a very good job of linting and prettification of the code, but you can set up a couple additional dependencies (e.g., prettier or prettier-eslint-cli) and add auto-formatting within “scripts”. It will cost you +90M in node_modules and I don’t think it’s worth it. Even VS Code has good tools for this. Of course, if you need it, why not.

Using Typescript

Now with TS
And it’s even working!

Note: ESBuild serve mode integrates quite poorly with tsc error checking, again because that is not part of its responsibilities. There are plugins to do the check but at the moment they have problems to integrate with “serve” mode. I will return to that later.

SASS?

Last but not least, I may want to have SASS support. Not 100%, because modern CSS has built-in support for the most necessary features like nesting and variables, but let it be. This can be useful for projects with simple CSS that don’t use frameworks like Tailwind, and, also, for projects with SASS-based styles like Bootstrap.

bun i -D sass esbuild-sass-plugin

…let’s check…

❯ du -hs ./node_modules
122M ./node_modules

+30M… huh. Good. Our first 100M! Come on, it’s just a development dependencies. Five Windows’95? who cares.

Add plugin to ESBuild scripts esbuild.js and esbuild-serve.js

import {sassPlugin} from 'esbuild-sass-plugin'
...
plugins: [
htmlPlugin(),
sassPlugin()
],

Change index.html and dashboard.html

    <link href="css/styles.scss" rel="stylesheet"/>

and rename the styles.css to styles.scss

// styles.scss
@import 'fonts.css';

$base-color: #ff00a6;

body {
display: flex;
font-family: system-ui;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
margin: 0;
min-height: 100vh;

h1 {
color: lighten($base-color, 10%);
}

h2 {
color: darken($base-color, 21%);
}

img {
width: clamp(100px, 25vw, 350px);
}
}

Run build bun start and voila:

TS + SCSS

GitHub repository: https://github.com/lexey111/insane-nano-advanced

Live page (another useless): https://lexey111.github.io/insane-nano-advanced/index.html

Approach two point one: Tailwind

Now I want to deal with a project that doesn’t use SASS, but instead uses Tailwind.

I get what I have on a previous stage, remove SASS and install one wonderful plugin:

bun i -D esbuild-plugin-tailwindcss

It will do everything for us.

❯ du -hs ./node_modules
112M ./node_modules

112M with Tailwind but without SASS. Not bad.

Then I create tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js}'],
theme: {
extend: {},
},
plugins: [],
}

Add a bit to build scripts esbuild.js and esbuild-serve.js

import {tailwindPlugin} from 'esbuild-plugin-tailwindcss'

...
plugins: [
tailwindPlugin(),
htmlPlugin()
],

Change styles.css to be global-styles.css, just to make a distinction

@import 'fonts.css';

@tailwind base;
@tailwind components;
@tailwind utilities;

a {
@apply text-blue-600 hover:text-red-600 transition-all duration-500
}

and HTMLs with typical class spaghetti:

...
<link href="css/global-styles.css" rel="stylesheet"/>
...
<body class="bg-slate-700 py-12 text-slate-700">
<div class="container mx-auto border border-slate-200 rounded-lg bg-white p-12 max-w-screen-md shadow-xl">
<h1 class="text-6xl font-light mb-4 text-amber-400 text-center">
Hello world | Tailwind
</h1>

<div class="mx-auto my-12 pb-2 border-0 border-b-2 border-b-slate-200 text-center">
<a href="dashboard.html">Dashboard</a>
</div>

<h2 class="text-2xl font-normal my-2 bg-slate-100 px-2 rounded-sm text-slate-400">JPEG</h2>
<img class="rounded-xl shadow-md shadow-slate-300 w-16 md:w-32 lg:w-48 mx-auto my-4" alt="just to check JPEG image processing" src="assets/cat.jpeg">

<h2 class="text-2xl font-normal my-2 bg-slate-100 px-2 rounded-sm text-slate-400">PNG</h2>
<img class="w-16 md:w-32 lg:w-48 mx-auto" alt="just to check PNG image processing" src="assets/gears.png">

<h2 class="text-2xl font-normal my-2 bg-slate-100 px-2 rounded-sm text-slate-400">SVG</h2>
<img class="rounded-xl shadow-md shadow-slate-300 my-4 w-16 md:w-32 lg:w-48 mx-auto" alt="just to check SVG image processing" src="assets/front.svg">
</div>

<script src="js/main.ts"></script>
</body>

Run bun start — great!

Tailwind — on duty

GitHub repository: https://github.com/lexey111/insane-nano-tailwind

Live page: https://lexey111.github.io/insane-nano-tailwind/index.html

Wow. It’s been a long journey… Thank you for being with me.

In the next article I added a pinch of professionalism: custom watcher, type check, stable rebuilds, passing errors and notifications to the browser.

--

--