Add Metro resolver plugin for load-mode resolution serving#69
Open
exo-nikita wants to merge 2 commits into
Open
Add Metro resolver plugin for load-mode resolution serving#69exo-nikita wants to merge 2 commits into
exo-nikita wants to merge 2 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the resolution half of Metro load-mode support by implementing a
metro-resolver.jsplugin 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: ImplementscreateResolveRequest(base?)and exportsresolveRequestfor wiring as Metro'sresolver.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-nativefalseredirects.New
stasis/src/metro-resolver.js: Public export adapter that re-exports from the plugins package, mirroring the pattern used formetro-transformer.Updated
stasis-core/src/state.js: ExtendedresolveBundled()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: ExportedEMPTY_MODULE_PATHconstant (.stasis/empty-module.js) for the synthetic empty module that represents browser/react-nativefalseredirects, 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 inmetro.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-resolverexport to bothstasis/package.jsonandstasis-plugins/package.json.Comprehensive test suite (
tests/metro-resolver.test.js): End-to-end tests covering: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.loadBundleis 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 orcontext.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