Limitations and traps
Traps first: behaviour that is documented and deliberate, but that you will not guess from a quick read of the API, and that fails quietly rather than loudly. The permanent limitations, the ones that follow from what CDNs and import maps are, come after.
Routing
Section titled “Routing”"react*"also matchesreact-domandreactive. A trailing*is a plain prefix match, not a package boundary. To mean only React, write"react"and"react/*". See Routes and matching.- A route pattern
"gh:*"never matches anything.gh:specifiers match asgithub:, so write the route as"github:*". - Object-form routes are ranked, not ordered. An exact key beats a glob, a longer
glob beats a shorter one, and
"*"comes last; insertion order only breaks ties. An explicit specifier is tested with and without its prefix, and the highest-ranked route that accepts either form wins: with{ "npm:*": a, "react": b }, the specifiernpm:reactgoes tob. Use the array form (route(...)) when you want first-match order. - A URL string is only shorthand at the top level of a route. Inside
fallback(),race()and the other strategies a string throwsTypeError: wrap URL strings with custom() inside strategies.
Lockfiles and the CLI
Section titled “Lockfiles and the CLI”- Lock keys are the specifier as written.
react@^19andreact@19are different keys, so a pin applies only when the specifier is spelled exactly as it was when the lockfile was made. See lockKey(). router.build()returns every resolution this router has made, including earlierresolve()andimport()calls, not only this build’s specifiers. The lockfile you passed in only pins and is never copied across, so specifiers you stopped building are pruned. Use a fresh router per build if the lockfile should contain exactly one build’s inputs.- A prebuilt router in
mport.config.mjscan’t take a lockfile.--lockand--relockwith one are an error, andbuildwrites the import map but leaves the lock file alone. Export a function instead:export default ({ lock }) => createRouter(routes, { lock }). - Two specifiers that map one import-map key to different URLs throw.
react@18andreact@19in onebuild()is aResolutionErrornaming the key; give the second its ownscopesentry, or opt in withconflicts: "scope"(next two items). conflicts: "scope"only scopes what the build’s packages depend on. Dependents are the npm packages named in the build, not their transitive dependencies: listlib-a-utilstoo, or add an explicit scope. JSR and GitHub packages have no manifest the router reads. A conflicting version nobody depends on lands in no scope (the report lists it asunscoped), and the page’s own modules only ever see the first-listed version.- A scope does nothing for CDNs that import by URL. Scopes change only what a bare
specifier resolves to. esm.sh and jsDelivr
+esmrewrite their dependency imports to absolute URLs, so the scope is generated but has no effect; it matters for jspm, raw jsDelivr/unpkg andlocal(). See Conflicting versions. graphis bounded, same-origin and tokenizer-level. It stops atmaxFiles(500) andmaxDepth(20) and only reports it as atruncatedevent and inresult.graph(the CLI prints a warning): a truncated lockfile is partial, so treat the warning as an error in CI. It follows static imports on the module’s own origin, with a tokenizer, not a JavaScript parser: dynamic imports with computed arguments, workers, fetched assets and a second CDN’s files are not hashed. It downloads every file at build time, and an import map’sintegrityis enforced only by engines that implement it. See Whole-graph integrity.mport updatemoves within the range and leaves the import map alone. It never crosses a major (change the specifier andbuild), runmport buildafterwards, and with afilesmap it re-walks and re-records every file hash, not only the selected packages’. Seemport outdatedandmport update.- A prefix specifier (
lit/) can skip a provider. Raw file CDNs skip it for packages with anexportsmap (subpaths such aslit/decorators.jswould 404), andjsDelivr({ esm: true })always skips it. The route falls through to e.g. esm.sh, solit/may be served by a different CDN thanlit. - A pinned
entryis trusted to be ESM. Lockfile entries skip the entry lookup and the CommonJS check entirely.
Registry lookups and caching
Section titled “Registry lookups and caching”- A long-lived router never re-reads
latest. Registry lookups are memoized per router for its whole lifetime (failed lookups are forgotten and retried). Create a new router when you want fresh dist-tags. cache()is keyed on the specifier as written, so a hit makes no request at all (it works offline). The flip side: a range stays pinned to whatever it resolved to until the record expires (ttl) or the store is cleared. And the router writes every successful resolution into everycache()node in its whole route table, not only the route that served it.- An exact version is used as written, even if it doesn’t exist. No lookup happens
for
[email protected]; a typo surfaces later as a probe failure, not aResolutionError. resolveVersions: falsehands the CDN the range as written (for examplehttps://esm.sh/react@^19?target=es2022), so the CDN, not mport, picks the version. Providers that need an entry file (the raw CDNs) skip a range or tag and the route falls through.- The
latesttag wins when it satisfies the range, and deprecated versions are passed over, as in npm, soreact@^19may not be the highest matching version. - Prereleases only match a range that names one.
^20does not match20.0.0-rc.1;>=20.0.0-rc.0does.
Probing, health and failover
Section titled “Probing, health and failover”probe: "none"records no health data. Nothing was checked, so circuits never open andadaptive()never learns. It is for build-time routing you trust.verified()trusts the first download when no hash is pinned (trust on first use), and it downloads even withprobe: "none". With the default"head"probe the download is the probe (oneGETper candidate); with another probe it follows it.- Integrity covers the entry module only.
verified()hashes the one URL it selected, not the modules that file imports in turn. router.import()only resets the failure streak when the import completes. A mirror that passes the probe but fails to import accumulates failures and its circuit opens;resolve()andbuild()never import, so there a passing probe still resets it.- A circuit is half-open after
reset. The failure streak is not reset when the circuit closes again, so the next failure reopens it immediately; only a success closes it for good. - A per-call
onEventreplaces the router’s handler, it does not add to it. Exceptions thrown by a handler are swallowed. traceis a live array. After arace(), events from the losing probes can still be appended for a moment afterresolve()returns.router.import()’s import failures are not in any trace. They are reported only toonEvent({ type: "fail", phase: "import" }).- Runtime failover can switch builds. Without a lockfile entry or
options.build,router.import()may move from esm.sh to jsDelivr’s+esmbundle. Switching to a raw"npm"build at runtime only works when the page’s import map already covers that build’s own bare imports.
In the browser and the v1 API
Section titled “In the browser and the v1 API”-
Firefox ignores an import map added after any module has loaded (155, the version the browser tests run), so
startup()andinjectImportMap()work in Chromium and WebKit only. Firefox logs “Import maps are not allowed after a module load or preload has started” and leaves bare specifiers unmapped; mport is itself a module, so it is always too late.startup()now notices (it asksimport.meta.resolve()) and rejects with anErrorcarrying the build result aserror.result, instead of leaving bare imports to fail later with aTypeError. What works in all three engines: a map in the HTML before any module script (build it ahead of time and userenderImportMap()), orcreateImporter(), which needs no map. Permanent until Firefox ships late or multiple import maps; a browser test fails the day it does. -
The import map must come before any
modulepreloadlink. Firefox (155) ignores an import map that follows amodulepreload(the same rule as a late map), so every bare import fails there; Chromium and WebKit accept either order. Docs and example 12 printrenderImportMap()first, thenrenderModulePreload(); a browser test pins it per engine. -
local()asks the registry unless you give itinstalledRegistry(). A package that is not on npm is aResolutionError, and a published one resolves to the registry’slatest, not the copy you serve.installedRegistry({ root })(Node only, from@johnhenry/mport/node) reads<root>/<name>/package.jsoninstead; it does not walk up parentnode_modules, and a vendored copy has nointegrity(graphskips origin-relative URLs, listing them ingraph.skipped). -
A CSP hash covers the map’s exact text. Re-serialising the page (a minifier, CRLF conversion) changes the text and the browser blocks the map: write
renderImportMapCsp()’shtmlout unchanged and recompute the hash whenever the map changes. The hash functions are async (Web Crypto, a secure context in browsers), and underrequire-trusted-types-for 'script'aninjectImportMap()map is refused, so put it in the HTML. -
dependenciesexpands only declared, raw-CDN dependencies. esm.sh, jsDelivr+esmand jspm are not expanded; a module that imports something it does not declare, a Node built-in or a CommonJS dependency is not helped (the dependency lands inskipped); onlydependenciescount, not dev, peer or optional ones; nestednode_moduleslayouts are not modelled bylocal()+installedRegistry(). -
startup()has to win the race with your modules. The import map must be in the document before the first module that uses it resolves: put the startup code in its own<script type="module">before the rest, or generate the map at build time. -
The v1 functions don’t resolve ranges. With no version they use
latest, and a range is handed to the CDN. AuseCache: "localhost"hit whose import fails rejects rather than racing again, and the default v1 race still mixes builds (raw jsDelivr/unpkg files against jspm’s transformed output). For consistent builds, use a router such ascreateRouter({ "*": race(jsDelivr(), unpkg()) }).
Bundler plugins
Section titled “Bundler plugins”"external"mode picks one URL per import at build time. There is no runtime failover and nointegrity(a URL in animportstatement can’t carry one). Usemode: "importmap"to keep bare imports and get a map withintegrityand scopes.- Only imports the bundler reports are routed, and a routable package whose lookup fails fails the build. Vite’s dev server is untouched by default (it pre-bundles itself), so dev and production can differ. Tested against Rollup 4 and Vite 8; other majors are untested. See Bundler plugins.
Permanent limitations
Section titled “Permanent limitations”These follow from what raw CDNs, import maps and the npm registry are, not from mport.
- Raw file CDNs serve packages exactly as published. A CommonJS entry can’t be
imported by a browser, and mport’s detection of CommonJS is a heuristic over
package.json(file extension,type,module, export conditions, naming conventions): a.jsES module with none of those signals is skipped, and a CommonJS file that looks like ESM is served. Raw ES modules also keep their own bare imports (import "preact"), which only resolve if the page’s import map covers them (build(…, { dependencies: true })adds the ones a manifest declares); ESM-transforming CDNs (esm.sh, jsDelivr+esm) rewrite those. The exact rules: CommonJS detection. - Import maps have no runtime fallback. The platform lets a specifier map to one URL
and gives no hook to retry when that fetch fails, so a map built with
startup()or the CLI is only as available as the mirror it chose. Failover after page load exists only for loads that go throughrouter.import()/createImporter(), and switching builds at runtime only works when the new build’s own imports resolve. Permanent until import maps grow a fallback mechanism. - A probe proves availability, not correctness.
probe: "head"learns that a URL answers, not that it is an ES module that will evaluate;probe: "none"checks nothing and records no health;resolveVersions: falsehands the CDN a range it resolves on its own (so providers that need an entry file, the raw CDNs, skip a range and fall through).verified()hashes the entry module only;build(…, { graph: true })hashes the modules it imports in turn too. Either checks bytes only against a hash you already have: without a pinnedintegrityit records whatever the first mirror served (trust on first use). By design: stronger checks cost a download per candidate. - The npm registry answers an unknown package with a 404 that carries no CORS
header. In a browser that surfaces as a network error, so “this package doesn’t
exist” and “the registry is unreachable” are the same
ResolutionError(its message says so). Registry lookups also cost a request per package per page load; resolve at build time or ship a lockfile to avoid both. A property of registry.npmjs.org, not of mport.