Skip to content

Add Metro resolver plugin for load-mode resolution serving#69

Open
exo-nikita wants to merge 2 commits into
mainfrom
claude/metro-resolver-bundle-ehhaaa
Open

Add Metro resolver plugin for load-mode resolution serving#69
exo-nikita wants to merge 2 commits into
mainfrom
claude/metro-resolver-bundle-ehhaaa

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

Summary

Adds the resolution half of Metro load-mode support by implementing a metro-resolver.js plugin that serves recorded module resolution edges from the bundle in Metro's main process, complementing the existing per-worker transformer.

Key Changes

  • New stasis-plugins/src/metro-resolver.js: Implements createResolveRequest(base?) and exports resolveRequest for wiring as Metro's resolver.resolveRequest. Serves recorded (parent, specifier) → resolved file edges from the bundle, deferring unrecorded requests to Metro's default resolver or a custom base resolver. Handles per-platform edges by selecting the appropriate target based on the platform Metro passes. Translates the reserved empty-module path to Metro's native { type: 'empty' } resolution for browser/react-native false redirects.

  • New stasis/src/metro-resolver.js: Public export adapter that re-exports from the plugins package, mirroring the pattern used for metro-transformer.

  • Updated stasis-core/src/state.js: Extended resolveBundled() to accept an optional { platform } parameter, allowing callers with platform context (the Metro resolver) to select targets from per-platform edge maps. Platform-less callers treat such edges as unserved and defer.

  • Updated stasis-core/src/util.js: Exported EMPTY_MODULE_PATH constant (.stasis/empty-module.js) for the synthetic empty module that represents browser/react-native false redirects, enabling consistent translation between the bundle's representation and Metro's native empty resolution.

  • Updated stasis-plugins/src/metro.js: Clarified documentation that load mode now involves three pieces (serializer, transformer, resolver) and that all should be wired permanently in metro.config.js.

  • Updated stasis-plugins/src/metro-transformer.js: Updated comments to reference the new resolver companion and clarify the division of labor (resolution in main process, bytes per-worker).

  • Updated package exports: Added ./metro-resolver export to both stasis/package.json and stasis-plugins/package.json.

  • Comprehensive test suite (tests/metro-resolver.test.js): End-to-end tests covering:

    • Serving recorded edges even when targets are removed from disk
    • Deferring unrecorded requests to the delegate
    • Out-of-root origin handling
    • Scope gating (full vs. node_modules)
    • Per-platform edge selection from multi-platform artifacts
    • Composition with custom base resolvers
    • Error handling when no fallback delegate is available
  • Test helper (tests/metro-resolver-run.helper.js): Spawnable helper that drives the resolver in isolation, capturing which requests are served from the bundle vs. deferred.

Implementation Details

The resolver operates only in load mode (when State.config.loadBundle is true) and is inert otherwise, allowing it to be wired permanently alongside the serializer and transformer. It uses the same scope gating as the transformer (full scope covers all in-root origins; node_modules scope covers only dependency origins) to ensure consistency. The resolver defers to a provided base function or context.resolveRequest (Metro's default), enabling composition with existing custom resolvers. Per-platform edges are only served when the platform matches a key in the recorded map, maintaining a fail-closed stance for unknown platforms.

https://claude.ai/code/session_01Hgm1DakWvP1hDUBAeZmuKo

claude added 2 commits July 17, 2026 19:17
…at bundle=load

Running a Metro build from a stasis bundle died in Metro's own resolver:
metro-resolver re-derives every resolution from the filesystem (Haste map +
node_modules walk + package.json redirects) in the main process, and none of
the stasis load machinery (ESM resolve hook, CJS _resolveFilename shim, --fs
readers) sits on that path. An attested-files-only tree carries the files
resolution LANDED ON at capture, not the alias layouts probing walks through
-- e.g. `@babel/runtime/helpers/interopRequireDefault` (injected by
transform-runtime into every transformed module, so the first bare specifier
any rebuild resolves) probes a `helpers/...` layout that was never loaded and
hence never attested, while the bundle carries both the resolved target's
bytes AND the exact (parent, specifier) -> file edge. Nothing consumed those
edges on the Metro side: UnableToResolveError with the answer sitting in the
bundle.

Add the missing consumer, `@exodus/stasis/metro-resolver` -- the RESOLUTION
half of load mode, completing the transformer (bytes) the same way webpack's
beforeResolve redirect and esbuild's onResolve already close this seam
in-process for their bundlers:

- `resolveRequest` / `createResolveRequest(base?)` implement Metro's stable
  `resolver.resolveRequest` contract, which runs exactly where Metro resolves
  (the main process). A recorded in-scope edge is served as
  `{ type: 'sourceFile' }` via State.resolveBundled -- the same
  condition-agnostic lookup the CJS require shim uses -- so resolution can't
  drift from what was attested and can't fail on layouts the attested tree
  doesn't carry. Everything else (out-of-scope origins, unrecorded edges --
  incl. asset imports, which carry no edge) defers to `base` or Metro's
  default resolver via `context.resolveRequest`; byte integrity stays with
  the worker transformer / getFile (this is an availability bridge, not a
  new trust gate).
- Scope mirrors the transformer and the loader's resolve-hook gate: full
  scope serves every in-root origin, node_modules scope only dependency
  origins. Inert outside bundle=load (a capture run must resolve naturally --
  that IS what gets recorded), so all three Metro pieces stay permanently
  wired in one committed metro.config.js per the #67 doctrine.
- State.resolveBundled gains a `{ platform }` option: a per-platform edge map
  (`stasis bundle --metro --platforms=...`) is resolved by the platform Metro
  hands each request, so one multi-platform artifact serves every platform it
  was built for; platform-less callers (the native-require shim) now treat
  such an edge as unserved and defer instead of crashing on a Map where a
  path is expected. The reserved `.stasis/empty-module.js` target (a
  browser/react-native `false` redirect; EMPTY_MODULE_PATH moves to
  stasis-core/util as the shared writer/reader constant) is translated to
  Metro's native `{ type: 'empty' }` so it never touches disk.

Covered by tests/metro-resolver.test.js (+ run helper, mirroring the
transformer suite; no Metro dependency): recorded edges served even with the
target deleted from disk, unrecorded edges deferred with arguments untouched,
out-of-root origins, node_modules-scope gating both ways, non-load
pass-through, per-platform edges from a real `--metro --platforms=ios,android`
artifact (ios/android hit; web / null platform defer; flat edge; empty-module
translation), base composition, and the no-delegate contract error.
Cross-referenced from the metro serializer/transformer docs; exported from
both packages and pinned in public-exports.test.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hgm1DakWvP1hDUBAeZmuKo
…w via extract

Two gaps surfaced by real use:

1. A misconfigured resolver fails SILENT: every request defers to Metro, which
   probes disk and dies with its own UnableToResolveError -- indistinguishable
   from the resolver not existing. The chief trap is structural: a load run
   executes the ATTESTED metro.config.js served from the bundle, so wiring the
   resolver into the on-disk config does nothing for an existing artifact --
   a fresh capture (which also attests the resolver's own module files) is
   required first. Document that in the wiring block, and add
   EXODUS_STASIS_DEBUG diagnostics: one activation line (mode, root,
   bundleFile, scope) plus per-request served / no-recorded-edge lines, so
   "wired but inert" (stale capture, root/cwd mismatch, non-load mode, static
   artifact without babel-injected edges) is pinpointable remotely.

2. Building with NO source tree on disk. Metro cannot do that from the bundle
   alone, resolver or not: metro-file-map enumerates files by crawling the
   watch folders with watchman or native `find` (child processes -- no fs seam
   to serve), the transform cache demands a file-map SHA-1 for every resolved
   file, and workers pre-read each file from disk before the stasis
   transformer swaps in attested bytes. None of that is interceptable through
   Metro config. The supported flow is to MATERIALIZE the attested tree first
   (`stasis extract`) and build there: the tree satisfies Metro's crawl /
   hashing / pre-reads, the resolver bridges the layouts that only exist as
   recorded edges (the tree carries exactly the attested files -- alias
   layouts like `@babel/runtime/helpers/*` behind a manifest redirect were
   never loaded, so they are not there to probe), and the transformer still
   feeds hash-verified bundle bytes to every transform.

   Making that flow real exposed an extract gap: State's root discovery
   refuses a directory holding a stasis artifact (the derived
   stasis.lock.json extract just wrote) with no package.json -- and a
   bundler-plugin capture never attests the root manifest (Metro reads it via
   its own fs), so `stasis run --bundle=load` in a fresh extracted tree died
   with 'Unexpected stasis config without package.json'. `stasis extract` now
   synthesizes a minimal { name, version } root manifest from the workspace
   bucket's attested identity -- the same basis `stasis prune` rewrites
   manifests from -- written only when absent after the planned writes, so an
   attested package.json or a pre-existing project manifest is never
   overwritten. Documented in doc/extract.md.

The new no-source-tree test drives the whole loop end to end with the
@babel/runtime/helpers edge shape (public specifier layout != resolved file):
capture via the serializer, wipe the entire tree, extract, then assert the
attested target materialized (and the alias layout did not), the synthesized
manifest roots the tree, the recorded edge resolves under lock=frozen where
disk probing has nothing to find, the debug channel names the mode and the
served edge, and the transform receives the bundle's bytes, not tampered disk
bytes. Cross-referenced from the transformer's KNOWN LIMITATION note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hgm1DakWvP1hDUBAeZmuKo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants