web / no js runtime

My laptop, my CI runners, and the VMs I run in production have no node, npm, or bun. My web apps still ship a TypeScript bundle. I build, check, and format it with native tools.

Why

My apps use a Go backend, hml templates, and a small TypeScript bundle with no npm dependencies. Standalone native binaries handle bundling, typechecking, formatting, and language servers.

Avoiding npm removes package scripts, lockfiles, dependency trees, and associated supply-chain attack vectors.

Tools

I install each tool as a standalone binary (see cmd / laptop). Production VMs need none of them. The server runs a single Go binary that embeds pre-built assets, saving disk space and build time.

Bundling

I bundle TypeScript with esbuild through its Go API. A Go program imports github.com/evanw/esbuild/pkg/api and calls api.Build. The program sets entry points, minification, and output names with content hashes. See web / cdn.

result := api.Build(api.BuildOptions{
	EntryPoints:  []string{"js/app.ts"},
	Bundle:       true,
	MinifySyntax: true,
	Outdir:       "public",
	EntryNames:   "[dir]/[name]-[hash]",
	Write:        true,
})
if len(result.Errors) > 0 {
	os.Exit(1)
}

Because Go compiles esbuild into a native binary, bundling needs no node process.

Typechecking

I check types with tsgo, the native TypeScript compiler. I run tsgo --noEmit against a standalone tsconfig.json. The configuration extends nothing:

{
  "compilerOptions": {
    "strict": true,
    "target": "es2022"
  },
  "include": ["js/**/*"]
}

The compiler reads types from its internal library, so it needs no packages from npm.

Formatting

I format source files with dprint. dprint runs as a single binary. It downloads WebAssembly plugins and formats TypeScript, CSS, HTML, JSON, and Markdown. It replaces prettier and needs no node runtime.

Language servers

For Neovim, I use language servers that do not need node:

Parsers

Tree-sitter generates parser code from grammar.js files. The tree-sitter binary embeds QuickJS, so it needs no external runtime. See vim / tree-sitter grammar.

Client dependencies

If I want a JavaScript library in the bundle, I vendor its source or I write the part I need. So far the part I needed was a combobox and a typeahead. The esbuild Go API bundles vendored source files directly.

A chart is the usual reason to add a large dependency such as d3. I compute the geometry in Go and draw with HTML and CSS instead. See web / charts.

Tradeoffs

Native tools compile the code, and only the browser executes JavaScript.

← All articles