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:
- Disk. A runtime and a dependency tree are the largest files on a small VM.
- Build time. A deploy copies one binary. It runs no install step.
- Attack surface. A box with no package manager runs no package script.
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:
- Bash: bashd, a Go server that calls
shellcheck. - HTML: superhtml, a single Zig binary.
- TypeScript:
tsgo --lsp --stdio, the same binary that typechecks.
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:
- Prettier plugins do not work with dprint.
- Package scripts in
package.jsondo not exist. - Tools that publish only to npm are unavailable.
None of those are problems for me, which makes it easy to make the tradeoff. In return, the boundaries and responsibilities feel very clear:
- Tools parse and compile the code.
- The browser runs client JavaScript.
- No machine I control runs JavaScript.