Skip to content

Commit fd9c2f3

Browse files
committed
good enough bundling
1 parent 508d7ad commit fd9c2f3

File tree

2 files changed

+150
-2
lines changed

2 files changed

+150
-2
lines changed

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: clean serve
1+
.PHONY: clean serve open
22

33
# aliases
44
run: ./out
@@ -19,4 +19,7 @@ clean:
1919
rm -rf ./out
2020

2121
serve:
22-
miniserve ./out --index index.html -v
22+
miniserve ./out --index index.html -v
23+
24+
open:
25+
start "http://[::1]:8080"

in/posts/good_enough_bundling.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
slug=good_enough_bundling
2+
title="Good enough" Javascript bundling
3+
description=(it's esbuild)
4+
author=quat
5+
created_date=Jan 24, 2025
6+
---
7+
Sadly I haven't touched [blanksky](https://github.com/quat1024/blanksky) in a while, but I wanted to write a bit more about its development setup, since I'm finding it's not a terrible way to write browser-destined Typescript.
8+
9+
## Overview
10+
11+
I write Typescript in the `./src` directory, bundle it into a single Javascript file under `./dist/script.mjs` using `esbuild`, and then point my browser at an `index.html` file which includes that script by name.
12+
13+
This is a toy project so I haven't implemented any kind of "build for distribution" mode yet. It wouldn't be hard: the to-be-distributed product consists of one HTML file, one stylesheet, and one JS file, which just need to be copied to the appropriate locations on a web server.
14+
15+
## Role of npm
16+
17+
Purely for provisioning packages. I didn't fill out any package metadata fields, and I don't use npm scripts because they're slow.
18+
19+
This is the entire `package.json`:
20+
21+
```json
22+
{
23+
"devDependencies": {
24+
"esbuild": "0.24.0"
25+
},
26+
"dependencies": {
27+
"@atproto/api": "^0.13.14",
28+
"facon": "^2.0.3"
29+
}
30+
}
31+
```
32+
33+
That's `esbuild` at dev time, plus some libraries I use at runtime. If you depend on libraries by putting them in your `package.json`, `esbuild` knows how to find them when bundling your project, without any extra configuration.
34+
35+
Managing `esbuild` through `package.json` is a great way to avoid dependency-hell problems that occur when you install it globally.
36+
37+
I personally gitignore `node_modules`.
38+
39+
## Invoking esbuild
40+
41+
From the command line, like this:
42+
43+
```sh
44+
esbuild ./src/main.ts --bundle --format=esm --tree-shaking=true --target=es2023 --outfile=./dist/script.mjs
45+
```
46+
47+
`esbuild` has a Javascript API for *really* in-depth configuration, but I have not needed to use it.
48+
49+
* `--bundle` - tells esbuild to bundle everything that `./src/main.ts` depends on
50+
* `--tree-shaking=true` - remove all symbols I don't use
51+
* `--target=es2023 --format=esm` - this is because I really wanted esbuild to create an ES2023 module.
52+
53+
You might want to pass `--sourcemaps` (for sourcemaps) or `--watch` (to automatically rebuild on changes); I manually rerun my script when I want to rebuild.
54+
55+
Then I import `/dist/script.mjs` from my html.
56+
57+
## Task running
58+
59+
npm has a `scripts` feature which allows you to put small shell scripts into the `package.json` file. However npm is ridiculously slow at finding and executing these scripts for some reason. I experimented with using a Makefile to hold my tiny scripts, but wasn't happy because it doesn't support argument-passing, and it's still pretty slow. I didn't want to install a dedicated task-runner program such as `just`, either.
60+
61+
So currently I'm using a `task.sh` shell script.
62+
63+
```sh
64+
#!/usr/bin/env sh
65+
set -eu
66+
67+
PATH=./node_modules/.bin:$PATH
68+
69+
build () {
70+
esbuild ./src/main.ts --bundle --format=esm --tree-shaking=true --target=es2023 --outfile=./dist/script.mjs
71+
}
72+
serve () {
73+
miniserve -v --index index.html .
74+
}
75+
open () {
76+
start "http://[::1]:8080"
77+
}
78+
79+
help () {
80+
echo "Available functions:"
81+
compgen -A function
82+
}
83+
84+
echo "--- ${1:-build} ---"
85+
eval "${@:-build}"
86+
```
87+
88+
This is based off [a trick Adrian Cooney described](https://github.com/adriancooney/Taskfile) as a "Taskfile".
89+
90+
The highlights:
91+
92+
* `PATH=./node_modules/.bin:$PATH` prepends "the folder NPM drops its programs inside" to the shell PATH. This means I can invoke `esbuild` instead of `./node_modules/.bin/esbuild`.
93+
* Then I have one function per "task": `build` invokes `esbuild`, `serve` runs [`miniserve`](https://github.com/svenstaro/miniserve) which starts a web server, and `open` opens localhost in my default browser.
94+
* The web server isn't an important piece; I don't need it in CI, and it's something I'm cool with installing separately instead of managing through npm.
95+
* `help` runs `compgen -A function`. This is a shell builtin intended to be used for tab-completion generation. Passing `-A function` make it print the names of all functions in the script, one-per-line; good for printing a list of all "tasks".
96+
97+
The magic part is `eval "${@:-build}"`, but `eval "$@"` also works. If you invoke the script like `./task.sh serve foo`, `$@` evaluates to `serve foo`, and `eval`ing that will call the shell function `serve` with an argument of `foo`. The `${@:-build}` syntax simply substitutes `build` if you didn't invoke the script with any arguments. (This isn't "safe"; `./task.sh ls` will run `ls` even though it's not a so-called "task" in the file, but that's okay with me.)
98+
99+
If your eyes glazed over that last paragraph: mine did too. `sh` sucks. But it works and it's preinstalled. Whatever.
100+
101+
I like to run `alias t=./task.sh`, then I can build by running `t` in the terminal.
102+
103+
## Typescript junk
104+
105+
There are three moving pieces in the Typescript world:
106+
107+
* `tsc`, the Microsoft Typescript compiler, which type-checks your code and transpiles it to Javascript. Famous for being slow.
108+
* `esbuild`, which can "compile" Typescript to Javascript by simply deleting all the type information. Of course it's faster: it doesn't do any typechecking.
109+
* VSCode, an editor which seems to understand Typescript well enough, and provides all the error messages I need.
110+
111+
I don't use `tsc`. Instead I use VSCode for realtime typechecking and `esbuild` for transpiling. The main caveat is that `esbuild` doesn't know if the program fails typechecking, and will happily transpile it anyway; and VSCode is *pretty* good about showing type errors but it's not 100% reliable, especially if an error occurs in a file you haven't opened yet. Just something to watch out for.
112+
113+
Here is my `tsconfig.json`:
114+
115+
```json
116+
{
117+
"compilerOptions": {
118+
"strict": true,
119+
"target": "ES2023",
120+
"lib": ["ES2023", "DOM"],
121+
"moduleResolution": "Bundler",
122+
"isolatedModules": true
123+
}
124+
}
125+
```
126+
127+
* `strict` is set purely because I like typescript strict mode. It only affects errors that appear thru VSCode.
128+
* `target` is *supposed* to control what `tsc` compiles your code into. But with `esbuild`, it only controls some corner-cases of how it turns Typescript into javascript I think? (I just made it match the `--target` I passed to `esbuild`.)
129+
* `lib` causes VSCode to bring in type hints for new ES2023 features and DOM APIs. `esbuild` doesn't care.
130+
* `moduleResolution` tells VSCode to resolve `import`s the same way bundlers like `esbuild` do.
131+
* `isolatedModules` forbids Typescript language features that don't work when using a naive transpiler. You can see the [isolatedModules documentation](https://www.typescriptlang.org/tsconfig/#isolatedModules) for a (short) list.
132+
133+
## Possible simplifications
134+
135+
Use Javascript instead of Typescript. Then you don't need a `tsconfig.json`. (Writing large JS programs without a type system drives me crazy, though.)
136+
137+
If you write Javascript and use ES6 modules, then you [don't even need a bundler](https://jvns.ca/blog/2024/11/18/how-to-import-a-javascript-library/) to import a respectable percentage of third party code. There are also CDNs like `unpkg`, if you can stomach the idea of tying your website to a third party server.
138+
139+
Instead of the "taskfile", you could use NPM scripts (if you can put up with how long it takes NPM to find your script), or use separate scripts `./build.sh`, `./serve.sh`, `./open.sh` (if you can deal with the clutter).
140+
141+
## Conclusions
142+
143+
* Optimizing the edit/compile/run cycle is important.
144+
* One-off shell scripts are nothing to be afraid of. Even on Windows, everyone has "git bash" installed, which is compatible enough with basic scripts.
145+
* You don't need a lot of moving parts to write browser Javascript.

0 commit comments

Comments
 (0)