esgun is a Go-native, npm-compatible package manager and esbuild bundler: it resolves npm dependencies straight from the registry, manages them (add/remove/update/outdated), audits them against OSV, and bundles your frontend, all in a single static Go binary. No node, no npm, no bun.
go install github.com/oddship/esgun@latest
cd my-frontend
esgun deps && esgun buildpackage.json stays fully standard (dependencies + a conventional scripts
block); the lockfile is standard package-lock.json (lockfileVersion 3, which
npm and Dependabot both accept); the build choreography lives in a tool-named
esgun.config.json next to it (the vite.config / eslint.config
convention).
The JavaScript toolchain is the odd dependency of most Go web projects: a Go binary that ships a frontend still needs node/npm/bun installed everywhere it's built, for what is usually a handful of leaf dependencies copied verbatim into the binary. esgun replaces that with Go: the registry protocol, semver resolution, lockfile management, security auditing, and bundling all run as Go (esbuild as a library), so Go 1.25.10 or newer is the only toolchain requirement.
As a deliberate security posture, esgun never runs lifecycle scripts:
nothing in the dependency tree ever executes during install, and it verifies
every tarball's SHA-512 against the registry's dist.integrity before
anything touches disk.
Three ways, depending on how you want to consume it:
1. PATH binary (for ad-hoc use anywhere):
go install github.com/oddship/esgun@latest2. Run without installing:
go run github.com/oddship/esgun@latest <cmd>3. Go tool dependency (recommended for projects): pin esgun in the
project's go.mod so the version is locked with the rest of the toolchain and
contributors get it automatically:
go get tool github.com/oddship/esgun@latestThis adds tool github.com/oddship/esgun to go.mod (with the resolved
version in require + go.sum). The project then invokes the pinned build
from anywhere:
go tool esgun depsBumping the tool is a normal go get tool github.com/oddship/esgun@vX.Y.Z,
same as any other Go dependency. The first go tool esgun run compiles it;
subsequent runs use the build cache.
esgun deps [--dir DIR] [--frozen] [-u] # install from lockfile
esgun ci [--dir DIR] # frozen install (fails on lockfile drift)
esgun install [--dir DIR] # alias of deps
esgun add <pkg>[@range] [--dev] [--dir DIR] # add/upgrade a dependency
esgun remove <pkg> [--dir DIR] # remove a dependency
esgun update [pkg] [--dir DIR] # re-resolve within declared ranges
esgun outdated [--dir DIR] # newer versions available
esgun audit [--dir DIR] # OSV vulnerability report (exit 1 on findings)
esgun build [--dir DIR] [--profile NAME] # bundle + assemble the output
esgun watch [--dir DIR] [--profile NAME] # rebuild on change
Flags: --dir DIR (default "."), --profile NAME (build/watch only),
--registry URL (or ESGUN_REGISTRY), --frozen, --dev, -u
mkdir my-frontend && cd my-frontend
esgun add dayjs # writes "dayjs": "^1.11.x" to package.json, installs
esgun add @codemirror/state --dev
esgun build # needs an esgun.config.json (see below)- Reads
package.jsonas a manifest (name/version list only; nothing executes it). - Resolves the full dependency graph against the registry (abbreviated packuments, exactly like npm itself), honoring npm-style semver ranges (
^,~,>=,||, …). - Reuses the existing
package-lock.jsonwhen a pinned version still satisfies its range (installs are reproducible).-ure-resolves everything. - Verifies every tarball's SHA-512 against the registry's
dist.integritybefore extracting. - Writes a
package-lock.json(lockfileVersion 3) that npm and Dependabot can consume (npm ci --dry-runpasses on it). - Extracts packages into
vendor/node_modules/<name>; nonode_modulesdirectory, no lifecycle scripts.
add/remove/update edit package.json (dependency maps stay alphabetically
sorted; every other field is preserved byte-for-byte) and then re-resolve the
graph, rewrite the lockfile, and refresh the vendor tree (stale packages
pruned):
esgun add dayjs: resolve latest stable (via the registrydist-tags), save^<version>, install.esgun add pkg@1.2.3saves the exact range;esgun add pkg@~0.7saves it verbatim;esgun add pkg@nextresolves a dist-tag.esgun remove dayjs: drop from package.json, lockfile, and vendor tree.esgun update: re-resolve everything within declared ranges (npm update semantics; package.json unchanged).esgun update dayjs: same, for one package.
devDependencies are resolved too (marked "dev": true in the lockfile);
unsatisfied peer dependencies and unavailable optional dependencies degrade to
warnings instead of errors.
- Bundles the configured
entrypoints with esbuild as a Go library (github.com/evanw/esbuild): ESM output, code splitting intochunks/[name]-[hash].js, minified, browser target. Package imports resolve through theresolvedirs (e.g.vendor/node_modules) via esbuildNodePaths. - The assembly choreography is project data, not tool code: an
esgun.config.jsonnext topackage.jsondeclares the output root, entry set (explicit paths or*globs), extra resolve roots, verbatim file/directory copies, curated tree copies, and watch dirs.
package.json stays fully standard: deps plus a conventional scripts block
("build": "esgun build", "watch": "esgun watch").
Libraries that publish several formats can replace the legacy entry and
outdir fields with named entry sets and profiles. Legacy manifests keep their
original minified, split ESM behavior unchanged.
{
"root": "dist",
"resolve": ["vendor/node_modules"],
"entrySets": {
"library": [
{ "input": "src/index.js", "output": "library" }
],
"components": [
{ "input": "src/components/*.js", "output": "components/[name]" },
{ "input": "src/components/*.css", "output": "components/[name]" }
]
},
"profiles": {
"esm-readable": {
"entrySets": ["library", "components"],
"outdir": "dist",
"format": "esm",
"suffix": ".esm"
},
"iife-readable": {
"entrySets": ["library"],
"outdir": "dist",
"format": "iife"
},
"iife-minified": {
"entrySets": ["library"],
"outdir": "dist",
"format": "iife",
"minify": true,
"suffix": ".min"
}
},
"copies": [
{ "from": "src/themes", "to": "dist/themes" }
]
}Entry output values are stable paths relative to a profile's outdir and
omit the generated extension. A glob input must use exactly one [name]
placeholder. suffix is inserted before both .js and .css, so library
with "suffix": ".min" becomes library.min.js.
format is either esm or iife; minify and splitting default to false.
IIFE profiles cannot use splitting. Self-contained component entry points
should also leave splitting disabled. target defaults to the legacy es2020
behavior and may be set to esnext globally or overridden by one profile when
a library should preserve modern syntax for smaller published artifacts.
esgun build executes every profile serially in lexical name order. Use
esgun build --profile esm-readable to build only one. A selected build still
replaces the complete root and runs the shared copies once, so it never leaves
stale outputs from other profiles. Builds are assembled in a sibling staging
directory and replace the previous output only after every bundle and copy
succeeds. Output aliases and copy destinations are collision checked.
Rebuilds on any change under the configured watch dirs (default src,
100 ms debounce). Uses inotify via fsnotify
plus a 300 ms mtime poll as a fallback, because host-mounted workspaces may not
deliver inotify events for host-side editor writes. --profile NAME applies the
same selection as build. Changes during a build coalesce into at most one
follow-up build.
esgun audit queries the free OSV database for every
resolved version in the lockfile and exits 1 if any vulnerabilities are found;
drop-in npm audit behavior for CI, without a node runtime.
MVP: single-package layout, flat resolution (no workspaces/monorepos), no lifecycle scripts, no private-registry auth, no publish command. All by design. Known follow-ups: unit tests for the resolver and review of semver edge cases beyond Masterminds/semver's coverage.