|
| 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