Skip to content

Registry, CommonJS detection and semver

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.

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(name: string, range: string | undefined, info: { versions, tags, deprecated? }): string

The 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(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(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:

  1. exports, mapped through resolveExports (conditions browser, import, module, default, in that order; require, node and types are ignored).
  2. A sub-path that exports doesn’t map: the sub-path itself.
  3. module.
  4. browser_module.
  5. browser when it is a string (object browser maps are ignored), else main, else index.js.

A leading ./ is removed.

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(pkg, subpath? = ""): string

entryInfo(pkg, subpath).file.

resolveExports(exportsField: unknown, subpath? = ""): string | undefined

Maps "." 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 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).