web / no js runtime

My laptop, my CI workers, and the VMs I run in production have no node, npm, or bun. My web apps still ship a TypeScript bundle. I keep the language, but I remove the JavaScript runtime from every machine I control. This article describes how I build, check, and format TypeScript with native tools.

Why

My apps use a Go backend, hml templates, one CSS file, and a small TypeScript bundle. The bundle imports nothing from npm. Without npm packages, developers keep a JavaScript runtime only to run tools. Those tools are a bundler, a typechecker, a formatter, and language servers. Single native binaries can do each job.

Package managers also add security risk. An npm install command can run package scripts with user permissions. Without npm, there are no package scripts, no lockfiles, and no install steps.

Tools

I install each tool as a standalone binary. See cmd / laptop for installation with macOS dotfiles. A provisioning script installs the same pinned versions on a CI worker. A production VM needs none of them. It serves a static Go binary with the bundle already built, so it has no runtime and no node_modules directory. That keeps three costs down:

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

My client bundle has no npm 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

This setup has limits:

None of those are problems for me, which makes it easy to make the tradeoff. In return, the boundaries and responsibilities feel very clear:

← All articles