Registry, CommonJS detection and semver
Registry helpers and CommonJS detection
Section titled “Registry helpers and CommonJS detection”createRegistry()
Section titled “createRegistry()”createRegistry({ fetch? = globalThis.fetch, npm? = "https://registry.npmjs.org", jsr? = "https://jsr.io" }?): RegistryClient| Method | Returns |
|---|---|
version({ registry, name, range }) |
the exact version per the resolution table |
info(registry, name) |
{ versions, tags, deprecated } for a package on "npm" or "jsr"; one request per package, however many ranges ask (JSR: yanked versions are left out, tags is { latest }) |
manifest(name, version) |
the package.json for one exact npm version (what conflicts: "scope" reads) |
entryInfo(name, version, subpath?) |
{ file, esm, hasExports } from GET <npm>/<name>/<version> |
entry(name, version, subpath?) |
entryInfo(...).file |
All memoized per client; failures are evicted. Errors are ResolutionErrors as listed
under Errors.
installedRegistry()
Section titled “installedRegistry()”import { installedRegistry } from "@johnhenry/mport/node";installedRegistry({ root, fallback? = false }): RegistryClient & { root: string }A registry client that answers from packages installed on disk, for
createRouter(routes, { registry: installedRegistry({ root }) }). It is the fix for
local() with a package that is not on npm (installed from git, file: or a workspace link),
and for a published package whose installed version must be the one served.
| Option | Meaning |
|---|---|
root |
the directory holding the packages, normally <project>/node_modules: <root>/<name>/package.json is read (scoped names are nested). A path or a file: URL. Required. |
fallback |
a registry client, e.g. createRegistry(), asked about packages that are not installed under root (false, the default, makes them a ResolutionError). Installed packages never reach it. |
| Method | Answer |
|---|---|
version({ registry, name, range }) |
the installed version. A dist-tag (latest) or no range means “whatever is installed”; a range the installed version does not satisfy is a ResolutionError ([email protected] is installed … does not satisfy "^19"). Only npm: JSR and GitHub requests go to fallback or fail. |
info(registry, name) |
{ versions: [installed], tags: { latest: installed }, deprecated: Set{} } |
manifest(name, version) |
the installed package.json; asking for another version (a lockfile pin that no longer matches what is installed) is a ResolutionError, or goes to fallback |
entryInfo(name, version, subpath?), entry(...) |
entryInfo() of that manifest: exports → module → main, with the same CommonJS judgement |
Each manifest is read once per client. A missing package is not installed under <root>; a
package.json that is not JSON or has no valid version is a ResolutionError naming the
file. The lockfile then records the installed version (local() has needsVersion: false,
so the import-map URL carries none).
import { createRouter, local } from "@johnhenry/mport";import { installedRegistry } from "@johnhenry/mport/node";
const router = createRouter( { "*": local({ base: "/node_modules/" }) }, { registry: installedRegistry({ root: "node_modules" }), probe: "none" },);await router.build(["@scope/unpublished"]); // /node_modules/@scope/unpublished/<entry from its package.json>Why a registry client and not a local({ packageRoot }) option. A provider only turns an
artifact into a URL and has to run in a browser; reading package.json from disk is a lookup,
which is what the router’s registry already abstracts (it also feeds conflicts: "scope" and
dependencies the manifests they need). One client therefore fixes every
consumer at once, any provider (jsDelivr() in a build that is checked against local files, custom()) can use it, and src/
stays free of node: imports.
What it does not do: it does not read the files it serves, so a vendored copy has no integrity
(graph skips origin-relative URLs, see Whole-graph integrity); it does not walk up
parent node_modules directories (give the root that holds the package); and it does not follow
symlinks specially (a workspace link is read through the link).
pickVersion()
Section titled “pickVersion()”pickVersion(name: string, range: string | undefined, info: { versions, tags, deprecated? }): stringThe choice registry.version() makes from registry.info(): a dist-tag name is that tag;
no range means latest; otherwise latest if it satisfies the range, else the highest
satisfying version, passing over deprecated ones unless nothing else matches. Throws
ResolutionError for an unparseable range or when nothing satisfies it.
outdated()
Section titled “outdated()”outdated(lock: Lockfile, { registry, names?, signal? }): Promise<{ outdated: OutdatedRow[], skipped: { key, reason }[] }>What mport outdated prints. For each lockfile entry (all, or those whose key, specifier or
package name is in names) on npm or JSR with an exact locked version: wanted is the
newest version the entry’s own range allows (an exact range is its own wanted) and
latest the registry’s latest dist-tag. A row is returned when updatable (wanted is
newer than current) or behindLatest (latest is). GitHub refs, entries with no exact
version, and entries whose lookup failed are returned in skipped with a reason instead
of failing the call. registry is any client with info(); router.registry works.
entryInfo()
Section titled “entryInfo()”entryInfo(pkg: packageJson, subpath? = ""): { file: string, esm: boolean, hasExports: boolean }The file to import for a package (or a sub-path), whether it is an ES module, and whether the package has an exports field (hasExports). The
file is chosen in this order:
exports, mapped throughresolveExports(conditionsbrowser,import,module,default, in that order;require,nodeandtypesare ignored).- A sub-path that
exportsdoesn’t map: the sub-path itself. module.browser_module.browserwhen it is a string (objectbrowsermaps are ignored), elsemain, elseindex.js.
A leading ./ is removed.
CommonJS detection
Section titled “CommonJS detection”Raw file CDNs serve files as published, and browsers can’t import CommonJS, so the
router skips raw providers when esm is false. The rules, first match wins:
| Rule | esm |
|---|---|
the file ends in .mjs |
true |
the file ends in .cjs |
false |
it was chosen through an import or module export condition (an inner import/module beats an outer browser/default) |
true |
the package has "type": "module", or the file is the module field |
true |
ESM by naming convention: *.module.js, *.esm.js, *.es.js (optionally with an extra extension such as .min), or inside an /esm/, /es/ or /module/ directory |
true |
| any other file | false |
These rules apply to files chosen in steps 1, 2 and 5. A file chosen from the module
field (step 3) is ESM unless it ends in .cjs; browser_module (step 4) is always ESM.
It is a heuristic in both directions: a .js file that is ESM but carries none of these
signals is skipped (pass allowCommonJS: true, or route the package to an
ESM-transforming CDN), and a file that carries a signal but is really CommonJS is served.
The check runs only for providers with needsEntry, only when an entry is looked up (no
path, or a path without an extension), and never for lockfile-pinned entries.
entryOf()
Section titled “entryOf()”entryOf(pkg, subpath? = ""): stringentryInfo(pkg, subpath).file.
resolveExports()
Section titled “resolveExports()”resolveExports(exportsField: unknown, subpath? = ""): string | undefinedMaps "." or "./<subpath>" through an exports field: string and array sugar,
condition objects (as above), exact keys, and * patterns (the longest matching
prefix wins; * in the target is replaced). Returns the file as written in exports
(with its ./), or undefined.
semver
Section titled “semver”semver is a namespace export with a small semver implementation, enough for the ranges
people write in import specifiers.
| Function | Meaning |
|---|---|
parse(v) |
{ major, minor, patch, pre: string[] } or null; a leading v and +build metadata are accepted; the prerelease is everything after the first - (1.0.0-rc-1 → pre: ["rc-1"]) |
valid(v) |
parse(v) !== null |
compare(a, b) |
-1, 0 or 1; prereleases sort before their release, numeric identifiers numerically |
satisfies(version, range) |
whether version is in range |
maxSatisfying(versions, range) |
the highest satisfying version, or null |
Range syntax: exact (1.2.3, =1.2.3), x-ranges (1, 1.2, 1.x, *, ""), ^,
~, comparators (>=, <=, >, <) including space-separated sets and a space after
the operator, hyphen ranges (1.2.3 - 2), and unions (||). A prerelease only matches
a comparator that names a prerelease on the same major.minor.patch (^20 does not
match 20.0.0-rc.1; >=20.0.0-rc.0 does). An unparseable range throws TypeError
(resolve() reports it as a ResolutionError).