Module Linking
Module Linking is the cross-application code sharing solution provided by Esmx. It is based on browser-native ESM (ECMAScript Modules) standards, letting multiple applications share code modules without any additional runtime library.
Core Advantages
- Zero Runtime Overhead: Uses the browser-native ESM loader directly, with no proxy or wrapper layer.
- Efficient Sharing: Dependencies are resolved at build time through Import Maps; modules load directly at runtime.
- Version Isolation: Different applications can use different versions of the same package, with coexisting majors isolated automatically.
- Simple to Use: Declarative configuration that stays fully compatible with native ESM syntax.
In short, module linking is a "module sharing manager" that lets different applications share code as safely and easily as using local modules.
The model: declare in package.json esmx
All protocol facts live in one package.json field, esmx, with exactly four optional sub-fields. entry.node.ts keeps only behavior (devApp, server, postBuild) plus environment links — putting protocol facts there is an error (E_PROTOCOL_IN_BEHAVIOR).
A module's declaration is strictly local knowledge: you can write it knowing nothing about any other module. Three roles cover every project.
Role 1 — provider (a shared platform package)
Consumers import 'shared/ui' — a logical name. Renaming src/ui/index.ts is no longer a breaking change.
Role 2 — consumer + provider (a feature remote)
Note what is absent: no imports map, no per-specifier wiring, no version field inside esmx. The version range lives where npm already puts it — dependencies (∪ peerDependencies) — and is validated at build time against the mounted artifact's actual version.
Role 3 — composer (the host)
uses is transitive: if cart uses shared, a host that uses cart gets shared's supply through the chain. Business apps declare one line and stay ignorant of the chain's depth.
How wiring is derived (you never write it)
Two rules replace every hand-written mapping.
The merge rule — one sentence:
supply(M) = merge(supply(uses[0]), …, supply(uses[n]), M.provides) — later entries override earlier ones, the module's own provides is the implicit last element, so the order of the uses array decides who wins when two modules provide the same package (list generic-to-specific; the specific layer wins, same convention family as Object.assign). Elections are per major version — coexisting majors (e.g. vue 2 and vue 3) are isolated islands, each with its own winner, and every consumer wires to the major satisfying its own declared range.
The lookup rule — applied per specifier as the bundler traverses your code, no pre-pass, no declaration:
Single-instance sharing is therefore inherent (one winner per package, the entire closure rewired to it), and multi-version coexistence needs zero extra vocabulary — a module that bundles its own copy is scope-isolated automatically. Type-only imports (import type) never produce wiring.
Mounting: where artifacts come from
Any module resolvable through node_modules auto-mounts at node_modules/<name>/dist — no path configuration. This covers registry installs and monorepo siblings: a pnpm workspace:* dependency symlink is followed and realpath'd, so a normal dependencies entry plus the uses name is the whole story.
Only for artifact directories that are not npm-resolvable (deploy paths, remotely fetched artifacts) do you add an explicit links entry. links is an environment fact, not a protocol fact, so it is the one modules key still allowed in entry.node.ts:
Importing shared code
Once a module is in your dependencies and uses, import its exports by logical name:
axios resolves to whatever module provides it (via provides: ["axios"]) in your merged supply table; shared/ui resolves to the logical export the shared module declared. You never name a physical path or write an import map by hand.
Verify your wiring: esmx validate
esmx validate is a build-free dry run of the whole resolution — mount walk, version checks, supply merge, export checks. Run it after every declaration edit:
It exits non-zero only when an error-severity diagnostic is found; warnings alone exit 0. A package without an esmx field reports protocol: "legacy" and exits 0. The full diagnostic taxonomy (E_NOT_LINKED, E_VERSION, E_SCHEMA, W_MULTI_CANDIDATE, …) is documented in the LLM briefing.
Complete example: multi-version coexistence
A shared base provides two majors of Vue side by side; each business app wires to the one its declared range satisfies — no manual imports anywhere.
Shared base (shared)
Vue 3 application (vue3-app)
Vue 2 application (vue2-app)
Aggregation host (host)
This shows:
- Shared base: provides multi-version framework support; version isolation comes from npm aliases (
npm:vue@^2.7.0) plus theprovidesdeclaration — each major is an isolated election group. - Vue 3 / Vue 2 applications: each declares its own Vue range and only exports its route config; the resolver wires each to the matching major automatically.
- Aggregation host: a single entry that composes the sub-applications — one
usesline per child, no manual import map.
Run esmx validate --json after building the children to confirm the whole graph resolves.
Legacy syntax (removed in the next major)
For NEW code, always use the
package.jsonesmxdeclaration above. The syntax below still works during the transition and you WILL see it in existing projects — recognize it, maintain it, and rewrite it to the new declaration to modernize. Do not write it for new modules.
Legacy projects keep all protocol facts in entry.node.ts under a modules key:
The legacy traps the new protocol removes:
- Public export names equal source paths. A legacy consumer writes
import { x } from 'shared/src/index'— the directory layout is the API, and renaming a source file breaks every consumer. Under the new protocol only logical names ('shared/ui') are public. - Wiring is manual. Every consumer hand-writes
importslines that the new protocol derives from declarations. - Nothing is validated until runtime. No version checks, no export checks, no structured diagnostics.
Converting all of this is a mechanical rewrite (codemod-able, but there is no shipped command). Per RFC 0001 the legacy syntax is removed entirely in a later phase — there is no long-term dual syntax.