unpm #
A simpler package manager for no-build websites.
Another package manager? #
Modern package managers are complicated. They use a complex algorithm to make sure shared transitive dependency versions work out, maybe do some fancy filesystem stuff to save disk space. Installation is non-deterministic, so they write out a special lock file just to reliably install the same dependencies. And of course, without another tool to bundle everything up, you can't even use the installed files in a browser.
unpm is different. It's a package manager designed for modern web technologies, specifically for websites with no build step.
- Rather than writing a bespoke configuration format, unpm uses a standard import map.
- Rather than installing packages only from special repositories, unpm installs dependencies from any website.
- Rather than using a special command to patch your dependencies, unpm lets you just edit the files.
- Rather than requiring you to use a compiler and bundler, unpm downloads files that you can serve directly with no build step.
- Rather forcing you to install your dependencies over and over, unpm is designed for you to just commit them to your repo.
Installation #
unpm requires Go. Once Go is installed, run:
go install tangled.org/jakelazaroff.com/unpm/cmd/unpm@latest
Getting started #
After installing unpm, create an unpm.json file at the root of your project:
{
"imports": {
"preact": "https://esm.sh/preact@10.19.3"
}
}
Your first reaction might be "that looks like an import map". And you'd be right: unpm.json files are also valid import maps!
Unlike other package managers, unpm lets you install packages from any website — esm.sh, cdnjs, GitHub raw links, even your own personal website!
Once you've filled out unpm.json with all your dependencies, run unpm vendor to download them locally:
unpm vendor
It will create a vendor folder that looks something like this:
vendor/
├── esm.sh/
│ └── [all esm.sh downloads go here]
├── importmap.js
├── importmap.json
└── jsconfig.json
Do not add this folder to your .gitignore! With unpm, your dependencies are meant to be vendored — committed to your source control.
Next, load importmap.js in your HTML files:
<script src="/vendor/importmap.js"></script>
Two important notes:
importmap.jsmust not be a module (notype="module"on the script tag)importmap.jsmust be loaded before any modules
That's it! Your dependencies will now work unbundled in a browser.
TypeScript types #
unpm can get TypeScript types in a few different ways:
- If a JavaScript file includes JSDoc comments, TypeScript can already check it!
- If the response for an import-map entry includes the header
x-typescript-types, unpm will download the type definition file at that URL. - Finally, you can add a
typesobject tounpm.jsonwith the URLs of type definition files.
An explicit types entry takes priority over response headers, so unused
automatic types aren't downloaded. If downloading explicit types fails, unpm
warns and tries the response header. Without usable types, it points TypeScript
at the JavaScript file. unpm does not probe for neighboring .d.ts files;
declaration files referenced by imports inside selected types are still resolved
and downloaded transitively.
You'll need to tell TypeScript where to find any vendored type definitions. You can do that by having your jsconfig.json extend the one that unpm generates:
{
"extends": ["./vendor/jsconfig.json"]
}
How does it work? #
Let's look at unpm.json again:
{
"imports": {
"preact": "https://esm.sh/preact@10.19.3"
}
}
That's an import map: a HTML feature that lets you define what happens when you run import { Component } from "preact";.
Normally, you'd put that in a <script type="importmap"> tag. When the page loads, the browser does the hard work of mapping module specifiers to URLs, downloading dependencies, following transitive imports and the like.
unpm does that same thing for you in development. When you run unpm vendor, it downloads everything to your local disk. Then it writes an importmap.js file — a small shim that injects a modified import map pointing to your local files rather than remote ones.
CLI #
The unpm command line interface is small: just three commands. Note that there are no commands for adding, removing or updating packages; instead, you should edit unpm.json directly and then run unpm vendor.
Flags go after the command and before positional arguments. For example:
unpm why --config custom.json vendor/example.com/lib.js
Use --silent for scripts and tests. It suppresses the spinner, verbose file
listings, and success messages. Warnings and errors still go to stderr as plain
text, and why still prints its import chain. --silent takes precedence over
--verbose; exit codes and generated files are unaffected.
vendor #
unpm vendor downloads all the modules listed in unpm.json and their imports,
then writes the output and removes files that are no longer needed. A failed
required download stops the command before it changes the output directory.
Failures downloading optional types or source maps produce warnings.
Pinned dependency files are preserved. The generated importmap.js,
importmap.json, and jsconfig.json are always regenerated regardless of pins;
an obsolete jsconfig.json is removed when there are no entries. Put dependency
settings in unpm.json and custom TypeScript settings in your own jsconfig.json
extending the generated file.
check #
unpm check finds any issues with the vendored modules:
- Any module specifiers within vendored files that aren't present in the
unpm.jsonimport map (error) - Any
unpm.jsonimport map entries that point to files that don't exist (error) - Any vendored files that aren't reachable from any
unpm.jsonimport map entries (warning)
If an error is found, unpm check will exit with code 1.
why #
Given a vendored file on disk, unpm why shows an import chain from an
unpm.json import map entry to that file. Both check and why follow
declaration imports such as ./jsx.js to their corresponding .d.ts files.
unpm.json #
unpm's configuration file is called unpm.json. It's also a valid import map — a guiding principle of unpm is that if you decide to stop using it, you should be able to simply use the contents of unpm.json itself as your import map proper, and your website will continue to work unaffected.
Note that while all unpm.json files are import maps, the converse is not true: unpm.json supports only a subset of the import map spec.
unpm.json supports three top-level keys: imports, types and unpm. Here's an example unpm.json` file:
{
"imports": {
"preact": "https://esm.sh/preact@10.19.3",
"validate": "https://raw.githubusercontent.com/jakelazaroff/validate.js/refs/heads/main/validate.js"
},
"types": {},
"unpm": {
"out": "./src/vendor"
}
}
imports #
imports is a module specifier map that maps between module specifiers and URLs. It's a bit stricter than actual import maps: you can only use "bare modules", meaning each property must resolve to a JavaScript file rather than a directory. In addition, each value must be a full URL.
{
"imports": {
"mylib": "https://example.com/mylib.js"
}
}
Unlike most package managers, unpm doesn't rely on an external package repository like npm or JSR. You can install packages from any URL on the Internet.
types #
types is a map from import specifiers to URLs of TypeScript type definition files.
Each import name selects its own types, even when multiple names share one
JavaScript URL. Explicit types take priority over the x-typescript-types
response header.
{
"imports": {
"mylib": "https://example.com/mylib.js"
},
"types": {
"mylib": "https://example.com/mylib.d.ts"
}
}
Each key must match a key in imports. You should only need to add entries here when auto-detection doesn't work.
unpm #
unpm is a map that holds unpm-specific configuration. All options are available as command line flags as well, but when repeatedly running commands it can be convenient to configure them from unpm.json.
outspecifies the directory to write any output files. Defaults to./vendor.rootspecifies the path at which the output files are available on your website. Defaults to/vendor.pinis a string array of glob patterns matching dependency file paths relative to the output directory. Matching dependencies won't be removed or updated when runningunpm vendor. Generated manifests are always regenerated, even with a pattern such as**. Pinning is mostly useful when you've made local changes to a dependency that you don't want to be overwritten.
To use unpm.json directly as an import map, remove the unpm key (browsers ignore unknown import map keys so it won't break anything, but there's no reason to keep it) and paste the rest into a <script type="importmap"> in your HTML.
Development #
Run make test for unit and integration tests, or go run ./test for just the
integration cases. Each case keeps its configuration, HTTP fixtures, and expected
output as ordinary files under test/; the runner leaves results in an ignored
actual/ directory beside the expectations. See test/README.md
for the case format and commands for running individual cases.
The implementation lives in internal/unpm. Start with cli.go, which resolves
configuration and renders command results, and vendor.go, which coordinates
downloading and writing. fetch.go handles individual HTTP responses, crawl.go
visits dependencies, and types.go contains type discovery and declaration rules.
imports.go scans references and rewrites their source spans; paths.go maps
URLs to vendored locations. inspect.go implements check and why, using
local.go to read the files currently on disk, including local edits.
FAQ #
(These aren't actually frequently asked; it's just a socratic way to give more context.)
Don't real web apps need a build step? #
Not at all! JavaScript has gotten really good; these days, you can get a similar developer experience without building anything. Multiple major frameworks have documentation on no-build setups.
Why do I need a package manager if I don't have a build step? #
You might not! You could hotlink to a CDN or download the files yourself.
In practice, though, there are a bunch of problems unpm solves beyond just downloading the files for you:
- If a library has multiple files, you'd need to vendor all of them and preserve the directory structure.
- If any of those files import URLs rather than paths, you'd need to manually change them to point to your disk.
- If you're checking types, you'd need to find the type definitions and configure TypeScript.
- If a transitive dependency is missing from your import map, you wouldn't know until you actually load your website.
Isn't it bad to commit dependencies to my repo? #
There are two main reasons most package managers advise you not to commit dependencies:
- Dependencies with native binaries will only work on a single platform.
- The folder can be really big — hundred of megabytes or even gigabytes of code.
That's it! It's true that both of these are fixed by not committing dependencies to your repository. But that introduces a ton of drawbacks:
- Since your dependencies are not committed to source control, you depend on an external system to build and run your app.
- Since installation across version ranges is non-deterministic, they need a lock file to make sure the exact same dependencies get installed.
- Since you can't just edit a dependency file, they need baroque workarounds to let you patch a dependency.
- Since you can't use the installed files in your browser, you need another tool to bundle everything together.
- In this era of Shai Hulud, on top of everything else, you need to carefully configure your package manager to protect yourself from supply chain attacks.
For some websites, all this might be unavoidable. But for many, these are tradeoffs that don't need to be made. For example, native binaries aren't an issue for websites, because code that runs in browsers is not platform specific. And the solution to a bloated dependencies folder is to download less code (and, ideally, use fewer dependencies in the first place).
If you're still unconvinced, htmx has a great essay on vendoring dependencies.