@intx/tool-packaging
v0.5.0
Published
Resolver and loader for the npm-distributed Interchange tool-package format
Readme
@intx/tool-packaging
Hub-side closure resolver and sidecar-side loader for the npm-distributed tool-package format. The contract below describes what the layered pipeline actually guarantees end-to-end; downstream code (the apply protocol, the session service, the deploy-flow integration tests) leans on it as written.
Loader resolution contract
A tool package's interchange.tools entry, and the JS body it pulls in
transitively, may import (or require) any bare specifier the
manifest's resolved closure satisfies. The loader builds a per-instance
nested node_modules/ layout under the scratch directory so each
package's direct dependencies are reachable through Node's standard
ancestor walk; the layout composes recursively, so a transitive's own
transitives are reachable from inside it.
Concretely the loader:
- Walks every manifest entry and validates that each one is
source-consistent: every entry the manifest says lives at a
registry resolves end-to-end against that registry; an asset entry
whose
package.formatis"tarball"resolves against the asset'sassetMountsmap; an asset entry whosepackage.formatis"source"(a git subtree, used by source-format workflow definitions) resolves against the separategitDirsmap. - Materializes every entry into the content-addressable tarball
cache: bytes pulled from the entry's source on a miss are verified
through
cache.put, and the bytes are then unpacked once viacache.extractTarball. A single sha512 produces a single extraction shared across instances. A"source"asset entry is the exception: it has no tarball and no SRI, so it bypasses the cache entirely — its subtree is checked out in place from the indexedgitDir(materializeGitEntry) and verified against the frozen gittreeOidrather than an sha512. - Lays out each entry under
<scratch>/store/<name>/<version>/by copying the file tree from the cache extraction — or, for a"source"entry, from its git checkout. Each layout directory then getsnode_modules/<dep>symlinked into it pointing at the siblingstore/<dep>/<depVersion>/chosen for that requirer. Symlinks at thenode_modules/boundary let Node's realpath-based resolver walk to the dep's own layout dir (which has its ownnode_modules/). - Dynamically imports each top-level entry's
interchange.toolsmodule and collects theAnnotatedToolFactory/AnnotatedPluginFactoryvalues it exports.
Properties that fall out of this layout:
- Closure satisfies itself. A top-level package's body, plus every
transitive it pulls in, resolves bare-specifier imports against the
manifest closure with no help from the sidecar host's own
node_modules. - No hoisting between top-levels. Each top-level keeps its own nested view; two top-levels that disagree on a transitive's version get two distinct entries in the store, both materialized.
- Version conflicts coexist. Diamond dependencies share a single
store entry (one symlink target per
(name, version)); divergent versions live as separate store entries and Node's ancestor walk picks the right one for each requirer. - Runtime-agnostic tarballs. The loader does no Bun-specific
resolution. A tool package whose
interchange.toolsentry is a.jsfile with no workspace-only deps loads identically on Node and Bun. The workspace's built-in tool packages bundle their entries throughbin/build-builtins.tsprecisely so the produced tarballs satisfy this property.
Optional-dependency semantics
The closure walker honors npm's "an optional subtree is best-effort"
contract: when an optionalDependencies edge fails to resolve — the
packument is absent, the range picks nothing, the registry rejects the
fetch — the whole subtree under that edge is dropped from the manifest
silently. A hard dep of an optional dep is part of that optional
subtree; a failure there drops the parent too, instead of leaving a
parent in the closure whose own hard requirement is missing. Failures
outside any optional subtree abort the walk and surface as a
manifest.invalid deploy-apply error.
Asset modification race
The hub-side AssetRegistrySource reads tarball packuments from a
package-registry asset at resolution time. The sidecar's
createPack then resolves the asset's main ref and serves the tarball
bytes the resolver pinned. A concurrent PUT or DELETE against the
asset's tarballs/ prefix in the window between the resolver's read
and the sidecar's fetch can cause the apply to fail with
tarball.missing or integrity.mismatch.
The substrate's writeTreePreservingPrefix primitive makes PUTs and
DELETEs against the asset atomic with respect to one another, but it
does not couple them to the sidecar's deploy-apply read. The
intentional behavior is: the apply fails loudly as a rejected
deploy-apply. There is no silent corruption —
the integrity check on the sidecar side guarantees that mismatched
bytes are rejected — but the operator does see a failed apply if the
asset is mutated concurrently with a launch.
A follow-up that snapshots the asset's commit SHA at resolution time and replays against it at apply time is in scope if this shows up in operator practice; it is not implemented today.
Call-time imports and import.meta.url
Tool packages may resolve files relative to their own on-disk location
at run time — import.meta.url, require.resolve(), or an
await import(new URL("./sibling.js", import.meta.url)) evaluated long
after the module first loaded. The loader imports each top-level
package's interchange.tools entry from a path under
<instanceDir>/packages/<deploy-id>/ that is never renamed, so a
module's import.meta.url stays valid for the life of the deploy.
The prior deploy's directory is retained across the next apply, so a
session still draining against an older deploy keeps resolving its own
files until a further apply supersedes it. See ASSET-LAYOUT.md for
the on-disk layout and retention rules.
Follow-up reference
- INTR-180: drizzle mock replacement for tests that introspect the package's resolver outputs.
