From 2f105d9cf7d478dce81d3af081a728aed6668d06 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:17:13 +0000 Subject: [PATCH 1/2] feat(plugins): add metro-resolver -- serve recorded resolution edges 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 Claude-Session: https://claude.ai/code/session_01Hgm1DakWvP1hDUBAeZmuKo --- stasis-core/src/state.js | 14 +- stasis-core/src/util.js | 9 + stasis-plugins/package.json | 4 +- stasis-plugins/src/metro-resolver.js | 152 ++++++++++++++ stasis-plugins/src/metro-transformer.js | 25 ++- stasis-plugins/src/metro.js | 32 +-- stasis/package.json | 2 + stasis/src/cmd/bundle.js | 10 +- stasis/src/metro-resolver.js | 8 + tests/metro-resolver-run.helper.js | 58 ++++++ tests/metro-resolver.test.js | 256 ++++++++++++++++++++++++ tests/public-exports.test.js | 11 + 12 files changed, 549 insertions(+), 32 deletions(-) create mode 100644 stasis-plugins/src/metro-resolver.js create mode 100644 stasis/src/metro-resolver.js create mode 100644 tests/metro-resolver-run.helper.js create mode 100644 tests/metro-resolver.test.js diff --git a/stasis-core/src/state.js b/stasis-core/src/state.js index cea3874a..be0af16b 100644 --- a/stasis-core/src/state.js +++ b/stasis-core/src/state.js @@ -1840,13 +1840,23 @@ export class State { // sibling requires Node's module cache used to hide from the resolve hook (the // Module._load shim in hooks.js observes them) -- so a miss here means the bundle simply // doesn't carry the target (an external), and we defer to Node rather than guess. - resolveBundled(parentURL, specifier) { + // + // A per-platform edge (a `stasis bundle --metro` multi-platform artifact) stores a + // Map where a flat edge stores a string. Only a caller with platform + // context can pick a target from it, so `platform` (an option, e.g. 'ios') selects that + // key; the platform-less callers -- the native-require shim, getImport's bucket-scan + // fallback -- treat such an edge as unserved and defer, mirroring getImport's own + // fail-closed stance for Map targets rather than feeding a Map where a path is expected. + // (The Metro resolver plugin is the platform-bearing caller: Metro hands it the build's + // platform per resolution.) A platform the map doesn't carry likewise defers. + resolveBundled(parentURL, specifier, { platform } = {}) { let parent try { parent = this.#canonicalFile(parentURL) } catch { return undefined } const spec = this.#canonicalSpecifier(parentURL, specifier) const matches = new Set() for (const [, byParent] of this.imports) { - const file = byParent.get(parent)?.get(spec) + let file = byParent.get(parent)?.get(spec) + if (file instanceof Map) file = typeof platform === 'string' ? file.get(platform) : undefined if (file !== undefined) matches.add(file) } return matches.size === 1 ? resolve(this.root, [...matches][0]) : undefined diff --git a/stasis-core/src/util.js b/stasis-core/src/util.js index 9512867d..5049f87a 100644 --- a/stasis-core/src/util.js +++ b/stasis-core/src/util.js @@ -11,6 +11,15 @@ const { realpathSync } = fs const sep = '/' +// Synthetic, project-relative path for the empty module a browser/react-native `false` +// redirect resolves to in a `stasis bundle --mainFields/--metro` artifact. The writer +// (stasis/src/cmd/bundle.js) carries it as a real (empty) CJS file so such an edge points +// at attestable bytes rather than a sentinel; readers that must translate the convention +// back into their consumer's own "empty module" notion (the Metro resolver plugin's +// `{ type: 'empty' }`) compare against this. One constant, shared, so writer and readers +// can never drift. +export const EMPTY_MODULE_PATH = '.stasis/empty-module.js' + // The full, finite universe of `format` strings stasis knows. Lockfile and bundle // parsers reject any value outside this set so a tampered or forward-incompatible // artifact fails closed at the schema boundary, not at a later string compare. diff --git a/stasis-plugins/package.json b/stasis-plugins/package.json index 50b2a7c7..438d2e8d 100644 --- a/stasis-plugins/package.json +++ b/stasis-plugins/package.json @@ -7,12 +7,14 @@ "./webpack": "./src/webpack.js", "./esbuild": "./src/esbuild.js", "./metro": "./src/metro.js", - "./metro-transformer": "./src/metro-transformer.js" + "./metro-transformer": "./src/metro-transformer.js", + "./metro-resolver": "./src/metro-resolver.js" }, "files": [ "src/esbuild.js", "src/metro.js", "src/metro-transformer.js", + "src/metro-resolver.js", "src/plugins.js", "src/webpack.js" ], diff --git a/stasis-plugins/src/metro-resolver.js b/stasis-plugins/src/metro-resolver.js new file mode 100644 index 00000000..eee309c9 --- /dev/null +++ b/stasis-plugins/src/metro-resolver.js @@ -0,0 +1,152 @@ +import { isAbsolute } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { State } from '@exodus/stasis-core/state' +import { EMPTY_MODULE_PATH } from '@exodus/stasis-core/util' + +// Companion to the StasisMetro serializer plugin and the worker transformer: the +// RESOLUTION half of load mode for Metro. +// +// WHY METRO NEEDS THIS (and webpack/esbuild don't): +// Metro resolves with its own in-process resolver (metro-resolver + the crawled file +// map) in the MAIN process -- it never consults Node's module resolution, so none of +// the stasis load machinery (the ESM resolve hook, the CJS Module._resolveFilename +// shim, the --fs reader patches) is on its path. It re-derives every resolution from +// the filesystem: name resolution probes literal layouts (`pkg/helpers/x` as +// `/pkg/helpers/x(.js|..js|...)`), package.json redirects, the Haste +// map. A bundle=load tree is attested-files-only -- it carries the files resolution +// LANDED ON at capture, not the alias layouts/manifests the probing walks through +// (e.g. `@babel/runtime`'s `helpers/*` specifiers whose package.json redirect lands +// on other files: only the targets were loaded, so only the targets are attested) -- +// so Metro's re-derivation dies with UnableToResolveError on paths the bundle serves +// perfectly well. The bundle already carries the ANSWER: capture records every graph +// edge as (parent, as-written specifier) -> resolved file (the serializer's +// addImport; the Node loader's, for a runtime capture). This plugin is the missing +// consumer: it replays those recorded edges through Metro's own extension point, +// `resolver.resolveRequest`, which runs exactly where the resolver lives (the main +// process), so no cross-process state is needed -- the same asymmetry that lets load +// bytes live in the per-worker transformer. +// +// HOW IT WIRES IN (metro.config.js) -- wire it PERMANENTLY, alongside the other halves: +// const { resolveRequest } = require('@exodus/stasis/metro-resolver') +// module.exports = { +// resolver: { resolveRequest }, // nested under `resolver` (unlike transformerPath, a top-level key) +// transformerPath: require.resolve('@exodus/stasis/metro-transformer'), +// } +// // run with EXODUS_STASIS_BUNDLE=load (+ EXODUS_STASIS_BUNDLE_FILE / LOCK / SCOPE), +// // OR a stasis.config.json with "bundle":"load", OR under `stasis run --bundle=load`. +// // Outside load mode this resolver and the transformer pass through, and under load +// // the StasisMetro serializer does -- one committed metro.config.js carries all three +// // and the mode picks the active pieces (the config is itself attested at capture, so +// // a load run must execute it unedited -- see the class note in metro.js). +// // An app with its own resolveRequest composes via createResolveRequest(theirs): +// // stasis serves recorded edges first and defers misses to theirs. +// +// WHAT THIS DOES AND DOESN'T GUARANTEE: +// - A recorded edge is served AS RECORDED: Metro's probing (platform suffixes, +// package.json fields, Haste) is short-circuited for it, so resolution can't drift +// from what was attested -- and can't fail on alias layouts the attested tree +// doesn't carry. Per-platform edges (a `stasis bundle --metro` multi-platform +// artifact) are served through the `platform` Metro hands each resolution, so one +// multi-platform artifact serves every platform it was built for. +// - This is an AVAILABILITY bridge, not a new trust gate: byte integrity stays where +// it is (the worker transformer / State.getFile, hash-verified, fail-closed). An +// edge the bundle doesn't record -- out-of-scope parents, asset imports (capture +// attests asset FILES but records no edge for them), anything new -- defers to +// Metro's own resolver, which needs the on-disk tree exactly as before; if that +// resolution lands on an in-scope file, the transformer still serves/verifies the +// attested bytes, so a deferred edge can pick only attested content. +// - KNOWN LIMITATION (same boundary as the transformer): serving resolution does not +// make bundle-only files buildable -- Metro still reads a resolved file from disk in +// the worker (and hashes it in the main process) before the transformer swaps in +// attested bytes, and its file map still crawls the watched roots. A browser/ +// react-native `false` redirect is the exception: the reserved empty-module edge is +// translated to Metro's native `{ type: 'empty' }`, so it never touches disk. +// +// Inert outside load mode: any other mode resolves to a null State and this defers +// every request untouched -- in a capture run Metro must resolve naturally, because +// that natural resolution IS what gets recorded. + +// Per-process load State, built once, lazily (Metro constructs its resolver well after +// config load, so this never races the stasis loader's init). Reuse the ambient preload +// when one exists -- under `stasis run` the resolver then consults the very State the +// loader serves from instead of parsing the bundle a second time, and in a capture run +// it goes inert without constructing a State at all (no write-target claims to collide +// with the preload's). Standalone Metro (env/stasis.config.json only, no loader) builds +// its own State exactly like the worker transformer does. +let stateInited = false +let loadState = null +function getLoadState() { + if (!stateInited) { + stateInited = true + const state = State.preload ?? new State(process.cwd()) + loadState = state.config.loadBundle ? state : null + } + return loadState +} + +// True iff `absolute` is a path the bundle is supposed to cover under load mode -- +// the ORIGIN gate: edges are replayed only for parents the bundle's scope owns, and +// everything else defers to Metro. Mirrors the worker transformer's inScope (and the +// Node loader's resolve-hook gate): out-of-root origins are out of scope; full scope +// covers every in-root origin; node_modules scope covers only dependency origins, so +// workspace/app code keeps resolving through Metro against disk. +function inScope(state, absolute) { + try { + state.relative(absolute) + } catch { + return false + } + if (state.config.full) return true + return state.inNodeModules(pathToFileURL(absolute).toString()) +} + +// Build a Metro `resolver.resolveRequest`: serve recorded resolution edges from the +// bundle, defer everything else. `base` is an app's existing custom resolver to defer +// to (so stasis composes in front of it); with none, misses go to Metro's default +// resolver via `context.resolveRequest` -- which Metro rebinds to the default algorithm +// before invoking a custom resolver, precisely for this delegation pattern. +export function createResolveRequest(base = undefined) { + if (base !== undefined && typeof base !== 'function') { + throw new TypeError('createResolveRequest(base?): base must be a function or omitted') + } + return function stasisMetroResolveRequest(context, moduleName, platform) { + const state = getLoadState() + if (state) { + const origin = context.originModulePath + if (typeof origin === 'string' && isAbsolute(origin) && inScope(state, origin)) { + // resolveBundled scans every conditions bucket for the recorded (parent, + // specifier) edge -- Metro resolutions carry no Node conditions to key on -- + // and picks the `platform` entry from a per-platform edge map. As-written + // specifiers ('./Button', '@babel/runtime/helpers/x') match symmetrically: + // capture recorded them via the same canonicalization. A miss (unrecorded + // edge, divergent buckets, platform the map doesn't carry) returns undefined + // and we defer below -- never guess. + const target = state.resolveBundled(pathToFileURL(origin).toString(), moduleName, { + platform: typeof platform === 'string' ? platform : undefined, + }) + if (target !== undefined) { + // The reserved empty module (a browser/react-native `false` redirect in a + // `stasis bundle --metro/--mainFields` artifact) maps to Metro's own notion + // of an empty resolution -- Metro then never tries to read the synthetic + // path from disk, which only the bundle carries. + if (state.relative(target) === EMPTY_MODULE_PATH) return { type: 'empty' } + return { type: 'sourceFile', filePath: target } + } + } + } + const next = base ?? context.resolveRequest + if (typeof next !== 'function') { + throw new TypeError( + 'StasisMetroResolver: context.resolveRequest is not a function -- Metro supplies it ' + + 'when resolver.resolveRequest is set; pass your own base resolver to createResolveRequest() ' + + 'if you are driving this outside Metro' + ) + } + return next(context, moduleName, platform) + } +} + +// Drop-in for the common case (no existing custom resolver): +// resolver: { resolveRequest } -- see the wiring example above. +export const resolveRequest = createResolveRequest() diff --git a/stasis-plugins/src/metro-transformer.js b/stasis-plugins/src/metro-transformer.js index 8bcaa08b..48b7f6c3 100644 --- a/stasis-plugins/src/metro-transformer.js +++ b/stasis-plugins/src/metro-transformer.js @@ -20,9 +20,10 @@ import { State } from '@exodus/stasis-core/state' // } // // run with EXODUS_STASIS_BUNDLE=load (+ EXODUS_STASIS_BUNDLE_FILE / LOCK / SCOPE), // // OR a stasis.config.json with "bundle":"load". Outside load mode this transformer is -// // a transparent pass-through (see getLoadState), and under load the StasisMetro -// // serializer is one too -- one committed metro.config.js carries both halves, the -// // mode picks the active one (the config is itself attested at capture, so a load +// // a transparent pass-through (see getLoadState), and so are the `./metro-resolver.js` +// // companion (the RESOLUTION half of load -- wire it too) and, under load, the +// // StasisMetro serializer -- one committed metro.config.js carries every piece, the +// // mode picks the active ones (the config is itself attested at capture, so a load // // run must execute it unedited). // // Metro calls `transformerPath`'s `transform(config, projectRoot, filename, data, @@ -41,13 +42,17 @@ import { State } from '@exodus/stasis-core/state' // bytes through untouched (mirrors webpack/esbuild load scope handling). // - KNOWN LIMITATION: this does NOT let Metro build with the sources fully absent // from disk. Metro reads each file from disk in the worker (and hashes it in the -// main process for its transform cache) BEFORE this transformer runs, and resolves -// package.json via its own fs in the main process. Serving those reads from the -// bundle would need fs interception across the main + worker processes (stasis -// --mock territory), which is larger than this seam. So today's guarantee is -// "build the bundle's attested bytes, fail closed on disk drift," not "build from -// a bundle with no source tree." This mirrors the documented node_modules-crossing -// gap in the webpack load path. +// main process for its transform cache) BEFORE this transformer runs, and its file +// map still crawls the watched roots. RESOLUTION, though, no longer depends on +// Metro's disk probing for recorded edges -- the companion `./metro-resolver.js` +// replays the bundle's attested (parent, specifier) -> file edges, so an +// attested-files-only tree (which lacks the alias layouts and manifests Metro's own +// probing walks through) resolves exactly as captured. Serving the remaining byte +// reads from the bundle would need fs interception across the main + worker +// processes (stasis --mock territory), which is larger than this seam. So today's +// guarantee is "build the bundle's attested bytes, fail closed on disk drift," not +// "build from a bundle with no source tree." This mirrors the documented +// node_modules-crossing gap in the webpack load path. const require = createRequire(import.meta.url) diff --git a/stasis-plugins/src/metro.js b/stasis-plugins/src/metro.js index 1d5cf1e2..603648ce 100644 --- a/stasis-plugins/src/metro.js +++ b/stasis-plugins/src/metro.js @@ -80,27 +80,31 @@ function metroDefaultSerializer() { // can see every module and resolution edge (mirrors webpack's afterResolve and // esbuild's onResolve/onLoad, which likewise run in the bundler's main process). // -// THIS PLUGIN IS THE CAPTURE HALF -- LOAD LIVES IN A SEPARATE TRANSFORMER: +// THIS PLUGIN IS THE CAPTURE HALF -- LOAD LIVES IN SEPARATE COMPANIONS: // The other plugins serve bundle bytes back into the build because their bundler // reads + transforms files in the same process the bundle lives in (webpack's // inputFileSystem wrapper, esbuild's onLoad). Metro reads + transforms in workers, // before serialization, so the serializer can't inject bytes into the transform. -// Load is instead handled per-worker by the companion `./metro-transformer.js` -// (wired as Metro's top-level `transformerPath`): unlike capture, load only ever -// READS the immutable bundle and every worker reads the same bytes, so there is no -// cross-worker state to merge. Under bundle=load this serializer is therefore a -// transparent PASS-THROUGH (captures nothing, writes nothing, delegates to the -// base serializer), mirroring the transformer, which passes through in every -// non-load mode. Wire BOTH halves permanently; the mode picks the active one. -// That matters because metro.config.js is itself attested at capture, so a load -// run must execute it unedited. Capture modes (lock/bundle add|replace|frozen, -// and lock=none/ignore) all capture here. +// Load is instead handled by two companions: `./metro-transformer.js` serves BYTES +// per-worker (wired as Metro's top-level `transformerPath`) and `./metro-resolver.js` +// serves RESOLUTION in the main process (wired as `resolver.resolveRequest`, +// replaying the edges captured here) -- unlike capture, load only ever READS the +// immutable bundle, so neither needs cross-worker state. Under bundle=load this +// serializer is therefore a transparent PASS-THROUGH (captures nothing, writes +// nothing, delegates to the base serializer), mirroring the companions, which pass +// through in every non-load mode. Wire ALL pieces permanently; the mode picks the +// active ones. That matters because metro.config.js is itself attested at capture, +// so a load run must execute it unedited. Capture modes (lock/bundle +// add|replace|frozen, and lock=none/ignore) all capture here. // -// USAGE (metro.config.js) -- prefer withStasis; wire the load half permanently alongside it: +// USAGE (metro.config.js) -- prefer withStasis; wire the load pieces permanently alongside it: // const { withStasis } = require('@exodus/stasis/metro') +// const { resolveRequest } = require('@exodus/stasis/metro-resolver') +// const base = require('./metro.config.base') // module.exports = withStasis({ -// ...require('./metro.config.base'), -// transformerPath: require.resolve('@exodus/stasis/metro-transformer'), // the load half +// ...base, +// transformerPath: require.resolve('@exodus/stasis/metro-transformer'), // load: bytes +// resolver: { ...base.resolver, resolveRequest }, // load: resolution // }, { /* scope, lock, ... */ }) // // Then CAPTURE with child-process forwarding ON -- Metro transforms in worker processes, so diff --git a/stasis/package.json b/stasis/package.json index a88e8513..e68c77e3 100644 --- a/stasis/package.json +++ b/stasis/package.json @@ -8,6 +8,7 @@ "./webpack": "./src/webpack.js", "./metro": "./src/metro.js", "./metro-transformer": "./src/metro-transformer.js", + "./metro-resolver": "./src/metro-resolver.js", "./loader": "./src/loader.js", "./mock": "./src/mock.js", "./bundle": "./src/bundle.js", @@ -43,6 +44,7 @@ "src/lockfile.js", "src/metro.js", "src/metro-transformer.js", + "src/metro-resolver.js", "src/mock.js", "src/parse.js", "src/prune.js", diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 1fe11a07..f18af6a6 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -11,7 +11,7 @@ import { createFieldResolver, resolveConditions } from '../resolve-fields.js' import { State } from '@exodus/stasis-core/state' import { brotliOptions } from '@exodus/stasis-core/brotli' import { sha512integrity } from '@exodus/stasis-core/state-util' -import { assertRealPathWithinBase, moduleFileKey, splitNodeModulesPath } from '@exodus/stasis-core/util' +import { EMPTY_MODULE_PATH, assertRealPathWithinBase, moduleFileKey, splitNodeModulesPath } from '@exodus/stasis-core/util' import { buildSolidityTree, collectSolidityFilesFromDisk, @@ -721,10 +721,10 @@ const SOURCE_EXTS = ['js', 'json', 'ts'] // react-native/browser conditions and platform suffixes, so the user supplies neither // --mainFields nor --conditions alongside it). const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main'] -// Synthetic, project-relative path for the empty module a browser/react-native -// `false` redirect resolves to. Carried in the bundle as a real (empty) CJS file -// so the edge points at attestable bytes rather than a sentinel. -const EMPTY_MODULE_PATH = '.stasis/empty-module.js' +// The empty module a browser/react-native `false` redirect resolves to is carried in the +// bundle as a real (empty) CJS file at the reserved EMPTY_MODULE_PATH (imported from +// stasis-core/util -- shared with readers that translate the convention back, e.g. the +// Metro resolver plugin), so the edge points at attestable bytes rather than a sentinel. // Build a JS/TS Bundle (and companion Lockfile) through the legacy-field resolver // (src/resolve-fields.js) rather than Node's resolver -- the `--mainFields` and diff --git a/stasis/src/metro-resolver.js b/stasis/src/metro-resolver.js new file mode 100644 index 00000000..3ee8da01 --- /dev/null +++ b/stasis/src/metro-resolver.js @@ -0,0 +1,8 @@ +// Public-export adapter: backs `@exodus/stasis/metro-resolver`. The resolver lives in +// @exodus/stasis-plugins (which depends on @exodus/stasis-core for State; it replays +// load-mode resolveBundled edges directly). Wire it permanently as Metro's +// `resolver.resolveRequest` (nested under `resolver`), alongside the worker +// transformer; it only acts under bundle=load (transparent pass-through otherwise). +// See the source for why resolution is served in Metro's main process while bytes +// are served per-worker. +export * from '@exodus/stasis-plugins/metro-resolver' diff --git a/tests/metro-resolver-run.helper.js b/tests/metro-resolver-run.helper.js new file mode 100644 index 00000000..34cda33d --- /dev/null +++ b/tests/metro-resolver-run.helper.js @@ -0,0 +1,58 @@ +// Drives the StasisMetroResolver in isolation (Metro isn't installed): imports the +// resolver and calls it the way Metro's module resolution would -- (context, moduleName, +// platform), with context carrying originModulePath and the default-resolver delegate +// as context.resolveRequest -- then prints what happened, so the test can assert which +// requests were served from the bundle and which deferred (and with what arguments). +// +// Run with cwd = the project root and EXODUS_STASIS_* env describing the load State. +// Spawn-per-test, like the transformer helper: the resolver caches its load State per +// process, and node --test isolates files but not cases. +// +// Usage: +// node tests/metro-resolver-run.helper.js '' +// requests: [{ "origin": "src/entry.js", "moduleName": "./hello.js", "platform": "ios" }, ...] +// `origin` is project-relative (resolved against cwd); `platform` defaults to null, +// matching what Metro passes for a platform-less resolution. +// -> prints JSON, one entry per request: +// { "served": } -- answered from the bundle +// { "deferred": {origin,moduleName,platform}, "result": } -- delegated +// { "error": "" } -- the call threw +// STASIS_TEST_COMPOSE_BASE=1 -- route through createResolveRequest(base) with the +// recording delegate as `base`; context.resolveRequest is then a poison function that +// must NOT be called (asserts base wins over Metro's default). +// STASIS_TEST_NO_FALLBACK=1 -- omit context.resolveRequest (and any base), so a +// deferral trips the resolver's own contract error. + +import { resolve } from 'node:path' + +const requests = JSON.parse(process.argv[2] ?? '[]') +const { createResolveRequest, resolveRequest } = await import('../stasis/src/metro-resolver.js') + +const compose = process.env.STASIS_TEST_COMPOSE_BASE === '1' +const noFallback = process.env.STASIS_TEST_NO_FALLBACK === '1' + +const out = [] +for (const { origin, moduleName, platform = null } of requests) { + // The recording delegate stands in for Metro's default resolver: it captures the + // arguments it was handed and returns a sentinel resolution. + let deferred = null + const fallback = (context, name, plat) => { + deferred = { origin: context.originModulePath, moduleName: name, platform: plat ?? null } + return { type: 'sourceFile', filePath: '' } + } + const poison = () => { + throw new Error('context.resolveRequest must not be called when a base is provided') + } + const fn = compose ? createResolveRequest(fallback) : resolveRequest + const context = { + originModulePath: resolve(origin), + ...(noFallback ? {} : { resolveRequest: compose ? poison : fallback }), + } + try { + const result = fn(context, moduleName, platform) + out.push(deferred ? { deferred, result } : { served: result }) + } catch (err) { + out.push({ error: err.message }) + } +} +process.stdout.write(JSON.stringify(out)) diff --git a/tests/metro-resolver.test.js b/tests/metro-resolver.test.js new file mode 100644 index 00000000..bc70e3c0 --- /dev/null +++ b/tests/metro-resolver.test.js @@ -0,0 +1,256 @@ +// End-to-end coverage for the StasisMetroResolver (the RESOLUTION half of load mode). +// +// Metro isn't a dependency, and the resolver never imports it -- it implements the +// `resolver.resolveRequest(context, moduleName, platform)` contract Metro's module +// resolution calls, with `context.resolveRequest` carrying the default-resolver +// delegate. So these tests spawn the resolver helper (cwd = a fixture project root) +// with a recording delegate and assert which requests are served from the bundle's +// recorded edges and which defer -- against real artifacts: bundles captured via the +// StasisMetro serializer helper (the runtime-capture shape) and a real +// `stasis bundle --metro --platforms=...` artifact (the per-platform edge shape). + +import { test } from 'node:test' +import { spawnSync } from 'node:child_process' +import { cpSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { stripVTControlCharacters } from 'node:util' + +const here = dirname(fileURLToPath(import.meta.url)) +const cli = join(here, '..', 'stasis', 'bin', 'stasis.js') +const captureHelper = join(here, 'metro-run.helper.js') +const resolverHelper = join(here, 'metro-resolver-run.helper.js') +const fullFixture = join(here, 'fixtures', 'metro-full') +const nmFixture = join(here, 'fixtures', 'metro-nm') +const fieldsFixture = join(here, 'fixtures', 'resolve-fields') + +const FULL_GRAPH = { + modules: [ + { path: 'src/entry.js', deps: [['./hello.js', 'src/hello.js']] }, + { path: 'src/hello.js', deps: [] }, + ], +} + +// Strip ALL inherited stasis env by prefix so a developer's exported EXODUS_STASIS_* +// can't leak into the spawned children and skew results (same as the sibling suites). +const cleanEnv = Object.fromEntries( + Object.entries(process.env).filter(([k]) => !k.startsWith('EXODUS_STASIS_') && !k.startsWith('STASIS_TEST_')) +) + +// Capture a bundle via the StasisMetro serializer path (writes lockfile + bundle). The +// serializer asserts child-process capture on a writing run, so enable it; the resolver +// side below is unaffected (load mode captures nothing). +const capture = (entry, { cwd, graph, env }) => { + const r = spawnSync(process.execPath, [captureHelper, entry], { + encoding: 'utf-8', + cwd, + env: { ...cleanEnv, EXODUS_STASIS_CHILD_PROCESS: '1', STASIS_TEST_METRO_GRAPH: JSON.stringify(graph), ...env }, + }) + r.stderr = stripVTControlCharacters(r.stderr) + return r +} + +// Drive the resolver over a list of {origin, moduleName, platform} requests; returns +// the helper's parsed JSON output alongside the raw spawn result. +const resolveAll = (requests, { cwd, env = {} }) => { + const r = spawnSync(process.execPath, [resolverHelper, JSON.stringify(requests)], { + encoding: 'utf-8', + cwd, + env: { ...cleanEnv, ...env }, + }) + r.stdout = stripVTControlCharacters(r.stdout) + r.stderr = stripVTControlCharacters(r.stderr) + return r +} + +const runCli = (args, opts = {}) => { + const r = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf-8', env: cleanEnv, ...opts }) + r.stderr = stripVTControlCharacters(r.stderr) + return r +} + +// mkdtemp can hand back a symlinked path (macOS /var -> /private/var) while the spawned +// helper resolves everything against its real cwd -- compare against the real path. +const withTmp = (fn) => (t) => { + const dir = mkdtempSync(join(tmpdir(), 'stasis-metro-resolver-')) + try { + return fn(t, realpathSync(dir)) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +const LOAD_ENV = (bundle) => ({ + EXODUS_STASIS_BUNDLE: 'load', EXODUS_STASIS_BUNDLE_FILE: bundle, + EXODUS_STASIS_LOCK: 'frozen', EXODUS_STASIS_SCOPE: 'full', +}) + +test('load mode serves recorded edges -- even with the target gone from disk -- and defers unrecorded ones', withTmp((t, tmp) => { + cpSync(fullFixture, tmp, { recursive: true }) + const bundle = join(tmp, 'snapshot.br') + const cap = capture('src/entry.js', { + cwd: tmp, + graph: FULL_GRAPH, + env: { + EXODUS_STASIS_LOCK: 'add', EXODUS_STASIS_SCOPE: 'full', + EXODUS_STASIS_BUNDLE: 'add', EXODUS_STASIS_BUNDLE_FILE: bundle, + }, + }) + t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) + + // Remove the target: Metro's own probing could never find it, but the recorded edge + // answers regardless of disk -- resolution comes from the bundle, not from probing. + rmSync(join(tmp, 'src', 'hello.js')) + + const r = resolveAll([ + { origin: 'src/entry.js', moduleName: './hello.js', platform: 'ios' }, + { origin: 'src/entry.js', moduleName: 'left-pad', platform: 'ios' }, // never recorded + ], { cwd: tmp, env: LOAD_ENV(bundle) }) + t.assert.equal(r.status, 0, `resolver stderr: ${r.stderr}`) + const [hit, miss] = JSON.parse(r.stdout) + t.assert.deepEqual(hit, { served: { type: 'sourceFile', filePath: join(tmp, 'src', 'hello.js') } }) + // The miss deferred to the delegate with the arguments untouched. + t.assert.deepEqual(miss.deferred, { origin: join(tmp, 'src', 'entry.js'), moduleName: 'left-pad', platform: 'ios' }) + t.assert.deepEqual(miss.result, { type: 'sourceFile', filePath: '' }) +})) + +test('an out-of-root origin defers untouched', withTmp((t, tmp) => { + cpSync(fullFixture, tmp, { recursive: true }) + const bundle = join(tmp, 'snapshot.br') + const cap = capture('src/entry.js', { + cwd: tmp, + graph: FULL_GRAPH, + env: { + EXODUS_STASIS_LOCK: 'add', EXODUS_STASIS_SCOPE: 'full', + EXODUS_STASIS_BUNDLE: 'add', EXODUS_STASIS_BUNDLE_FILE: bundle, + }, + }) + t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) + + const r = resolveAll([ + { origin: '../elsewhere/entry.js', moduleName: './hello.js' }, + ], { cwd: tmp, env: LOAD_ENV(bundle) }) + t.assert.equal(r.status, 0, `resolver stderr: ${r.stderr}`) + const [out] = JSON.parse(r.stdout) + t.assert.equal(out.deferred.moduleName, './hello.js') +})) + +test('node_modules scope serves dependency origins and defers workspace origins', withTmp((t, tmp) => { + cpSync(nmFixture, tmp, { recursive: true }) + // Give the dependency an internal edge so the serving side of the scope gate is + // observable: index.js -> ./util.js lives entirely inside node_modules. + writeFileSync(join(tmp, 'node_modules', 'fake-esm-pkg', 'util.js'), 'export const u = 1\n') + const bundle = join(tmp, 'snapshot.br') + const cap = capture('src/entry.js', { + cwd: tmp, + graph: { + modules: [ + { path: 'src/entry.js', deps: [['fake-esm-pkg', 'node_modules/fake-esm-pkg/index.js'], ['./helper.js', 'src/helper.js']] }, + { path: 'src/helper.js', deps: [] }, + { path: 'node_modules/fake-esm-pkg/index.js', deps: [['./util.js', 'node_modules/fake-esm-pkg/util.js']] }, + { path: 'node_modules/fake-esm-pkg/util.js', deps: [] }, + ], + }, + env: { + EXODUS_STASIS_LOCK: 'add', EXODUS_STASIS_SCOPE: 'node_modules', + EXODUS_STASIS_BUNDLE: 'add', EXODUS_STASIS_BUNDLE_FILE: bundle, + }, + }) + t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) + + const r = resolveAll([ + { origin: 'node_modules/fake-esm-pkg/index.js', moduleName: './util.js' }, + // Workspace origins resolve through Metro against disk in node_modules scope -- + // mirrors the loader's resolve-hook gate -- even for an edge the graph had. + { origin: 'src/entry.js', moduleName: 'fake-esm-pkg' }, + ], { + cwd: tmp, + env: { + EXODUS_STASIS_BUNDLE: 'load', EXODUS_STASIS_BUNDLE_FILE: bundle, + EXODUS_STASIS_LOCK: 'frozen', EXODUS_STASIS_SCOPE: 'node_modules', + }, + }) + t.assert.equal(r.status, 0, `resolver stderr: ${r.stderr}`) + const [dep, workspace] = JSON.parse(r.stdout) + t.assert.deepEqual(dep, { served: { type: 'sourceFile', filePath: join(tmp, 'node_modules', 'fake-esm-pkg', 'util.js') } }) + t.assert.equal(workspace.deferred.moduleName, 'fake-esm-pkg') +})) + +test('resolver is a transparent pass-through when not in load mode', withTmp((t, tmp) => { + // Committed fixture has scope=full config + a lockfile but bundle=none -> not load + // mode, so every request must defer to the delegate untouched (safe permanent wiring). + cpSync(fullFixture, tmp, { recursive: true }) + const r = resolveAll([ + { origin: 'src/entry.js', moduleName: './hello.js' }, + ], { cwd: tmp, env: {} }) + t.assert.equal(r.status, 0, `resolver stderr: ${r.stderr}`) + const [out] = JSON.parse(r.stdout) + t.assert.deepEqual(out.deferred, { origin: join(tmp, 'src', 'entry.js'), moduleName: './hello.js', platform: null }) +})) + +test('a --metro multi-platform artifact serves per-platform edges by the platform Metro passes', withTmp((t, tmp) => { + cpSync(fieldsFixture, tmp, { recursive: true }) + const bundle = join(tmp, 'metro.br') + const b = runCli(['bundle', '--metro', '--platforms=ios,android', `--output=${bundle}`, 'src/entry.js'], { cwd: tmp }) + t.assert.equal(b.status, 0, `bundle stderr: ${b.stderr}`) + + const env = { + EXODUS_STASIS_BUNDLE: 'load', EXODUS_STASIS_BUNDLE_FILE: bundle, + EXODUS_STASIS_LOCK: 'none', EXODUS_STASIS_SCOPE: 'full', + } + const r = resolveAll([ + { origin: 'src/entry.js', moduleName: './Button', platform: 'ios' }, + { origin: 'src/entry.js', moduleName: './Button', platform: 'android' }, + { origin: 'src/entry.js', moduleName: './Button', platform: 'web' }, // not in the map + { origin: 'src/entry.js', moduleName: './Button' }, // platform null (no context) + { origin: 'src/entry.js', moduleName: 'exportswins', platform: 'ios' }, // flat edge + // A browser/react-native `false` redirect: the reserved empty-module edge is + // translated to Metro's native empty resolution, never a disk path. + { origin: 'node_modules/redir/index.js', moduleName: './gone.js', platform: 'ios' }, + ], { cwd: tmp, env }) + t.assert.equal(r.status, 0, `resolver stderr: ${r.stderr}`) + const [ios, android, web, noPlatform, flat, empty] = JSON.parse(r.stdout) + t.assert.deepEqual(ios, { served: { type: 'sourceFile', filePath: join(tmp, 'src', 'Button.ios.js') } }) + t.assert.deepEqual(android, { served: { type: 'sourceFile', filePath: join(tmp, 'src', 'Button.android.js') } }) + // A platform the map doesn't carry, and no platform at all, defer -- never guess. + t.assert.equal(web.deferred.platform, 'web') + t.assert.equal(noPlatform.deferred.platform, null) + t.assert.deepEqual(flat, { served: { type: 'sourceFile', filePath: join(tmp, 'node_modules', 'exportswins', 'rn.js') } }) + t.assert.deepEqual(empty, { served: { type: 'empty' } }) +})) + +test('createResolveRequest(base) defers misses to the base, not context.resolveRequest', withTmp((t, tmp) => { + cpSync(fullFixture, tmp, { recursive: true }) + const bundle = join(tmp, 'snapshot.br') + const cap = capture('src/entry.js', { + cwd: tmp, + graph: FULL_GRAPH, + env: { + EXODUS_STASIS_LOCK: 'add', EXODUS_STASIS_SCOPE: 'full', + EXODUS_STASIS_BUNDLE: 'add', EXODUS_STASIS_BUNDLE_FILE: bundle, + }, + }) + t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) + + // The helper wires the recording delegate as `base` and poisons context.resolveRequest; + // a hit must not call either, a miss must reach the base (poison would throw). + const r = resolveAll([ + { origin: 'src/entry.js', moduleName: './hello.js' }, + { origin: 'src/entry.js', moduleName: 'left-pad' }, + ], { cwd: tmp, env: { ...LOAD_ENV(bundle), STASIS_TEST_COMPOSE_BASE: '1' } }) + t.assert.equal(r.status, 0, `resolver stderr: ${r.stderr}`) + const [hit, miss] = JSON.parse(r.stdout) + t.assert.deepEqual(hit, { served: { type: 'sourceFile', filePath: join(tmp, 'src', 'hello.js') } }) + t.assert.equal(miss.deferred.moduleName, 'left-pad') +})) + +test('a deferral with no delegate at all fails with the contract error, not a bare crash', withTmp((t, tmp) => { + cpSync(fullFixture, tmp, { recursive: true }) + const r = resolveAll([ + { origin: 'src/entry.js', moduleName: './hello.js' }, + ], { cwd: tmp, env: { STASIS_TEST_NO_FALLBACK: '1' } }) // non-load mode -> always defers + t.assert.equal(r.status, 0, `resolver stderr: ${r.stderr}`) + const [out] = JSON.parse(r.stdout) + t.assert.match(out.error, /context\.resolveRequest is not a function/) +})) diff --git a/tests/public-exports.test.js b/tests/public-exports.test.js index 60be7a61..826b06d3 100644 --- a/tests/public-exports.test.js +++ b/tests/public-exports.test.js @@ -10,10 +10,12 @@ import { StasisEsbuild } from '@exodus/stasis/esbuild' import { StasisWebpack } from '@exodus/stasis/webpack' import { StasisMetro } from '@exodus/stasis/metro' import * as metroTransformer from '@exodus/stasis/metro-transformer' +import * as metroResolver from '@exodus/stasis/metro-resolver' import { StasisEsbuild as PluginsEsbuild } from '@exodus/stasis-plugins/esbuild' import { StasisWebpack as PluginsWebpack } from '@exodus/stasis-plugins/webpack' import { StasisMetro as PluginsMetro } from '@exodus/stasis-plugins/metro' import * as pluginsMetroTransformer from '@exodus/stasis-plugins/metro-transformer' +import * as pluginsMetroResolver from '@exodus/stasis-plugins/metro-resolver' test('@exodus/stasis/bundle exports Bundle class', (t) => { t.assert.equal(typeof Bundle, 'function') @@ -36,6 +38,15 @@ test('@exodus/stasis/metro-transformer re-exports the stasis-plugins worker tran t.assert.equal(metroTransformer.getCacheKey, pluginsMetroTransformer.getCacheKey) }) +test('@exodus/stasis/metro-resolver re-exports the stasis-plugins resolver', (t) => { + t.assert.equal(typeof metroResolver.resolveRequest, 'function') + t.assert.equal(typeof metroResolver.createResolveRequest, 'function') + t.assert.equal(metroResolver.resolveRequest, pluginsMetroResolver.resolveRequest) + t.assert.equal(metroResolver.createResolveRequest, pluginsMetroResolver.createResolveRequest) + // createResolveRequest validates its optional base up front. + t.assert.throws(() => metroResolver.createResolveRequest('nope'), /base must be a function or omitted/) +}) + test('@exodus/stasis/cmd/bundle exports the bundle command and its in-memory API', (t) => { t.assert.equal(typeof buildBundle, 'function') t.assert.equal(typeof bundleCommand, 'function') From bbded5ebfc1f453e19a5094f289c526c1d5fcf14 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:52:49 +0000 Subject: [PATCH 2/2] feat(metro-resolver): debug diagnostics + a tested no-source-tree flow 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 Claude-Session: https://claude.ai/code/session_01Hgm1DakWvP1hDUBAeZmuKo --- doc/extract.md | 13 +++++ stasis-plugins/src/metro-resolver.js | 52 +++++++++++++++++- stasis-plugins/src/metro-transformer.js | 4 +- stasis/src/cmd/extract.js | 31 ++++++++++- tests/metro-resolver.test.js | 73 ++++++++++++++++++++++++- 5 files changed, 166 insertions(+), 7 deletions(-) diff --git a/doc/extract.md b/doc/extract.md index a8d672f4..d1700655 100644 --- a/doc/extract.md +++ b/doc/extract.md @@ -28,6 +28,19 @@ lockfile out of the box — `stasis prune` works directly against the extracted `node_modules`; a `stasis run --lock=frozen` additionally needs the project's `package.json` files, which are only present if the bundle recorded them. +When the bundle attests no root `package.json` (common for bundler-plugin +captures — Metro/webpack read manifests through their own `fs`, so a manifest +is attested only when it was `require()`d or captured as an `--fs` read), a +minimal `{ name, version }` one is synthesized from the workspace bucket's +attested identity — the same basis `stasis prune` rewrites manifests from. +Without it the extracted tree could not serve as a stasis root: State's root +discovery refuses a directory holding a stasis artifact (the derived +`stasis.lock.json`) with no `package.json`, which would block the +no-source-tree flow (`stasis extract` + `stasis run --bundle=load` in the +extracted tree). A `package.json` already present — attested in the bundle, +or pre-existing in the output directory — is never overwritten by the +synthesized one. + Legacy `version: 0` bundles record no package `name`/`version`, so no lockfile can be restored from them. Their sources are still extracted; the lockfile is skipped with a warning. diff --git a/stasis-plugins/src/metro-resolver.js b/stasis-plugins/src/metro-resolver.js index eee309c9..abe69746 100644 --- a/stasis-plugins/src/metro-resolver.js +++ b/stasis-plugins/src/metro-resolver.js @@ -42,6 +42,34 @@ import { EMPTY_MODULE_PATH } from '@exodus/stasis-core/util' // // An app with its own resolveRequest composes via createResolveRequest(theirs): // // stasis serves recorded edges first and defers misses to theirs. // +// NB -- WIRING CHANGES NEED A FRESH CAPTURE. Because a load run executes the ATTESTED +// metro.config.js (served from the bundle, not read from disk), adding this resolver to +// the on-disk config does NOTHING for an existing artifact: the old attested config -- +// without the resolver -- is what runs, and the resolver's own module files aren't in +// the bundle to be loaded anyway. Re-capture with the resolver wired, then load. Run +// with EXODUS_STASIS_DEBUG=1 to see whether the resolver is live and what it serves. +// +// NO-SOURCE-TREE BUILDS -- MATERIALIZE WITH `stasis extract` FIRST: +// Metro cannot build from a 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 each worker pre-reads its file from disk BEFORE the stasis +// transformer swaps in attested bytes. None of that is interceptable through Metro +// config. The supported no-disk flow therefore starts from the artifact and +// materializes the attested tree: +// stasis extract --output=work app.stasis.code.br # attested files + stasis.lock.json +// cd work && stasis run --lock=frozen --bundle=load --bundle-file=../app.stasis.code.br \ +// -- # invoke metro by real path; node_modules/.bin symlinks aren't bundle content +// The materialized tree carries EXACTLY the attested files -- which is where this +// resolver earns its keep: layouts that exist upstream but were never loaded (the +// `@babel/runtime/helpers/*` alias files behind a package.json redirect, manifests +// never read) are NOT in that tree, so Metro's own probing dies on them, while the +// recorded edges resolve straight to the attested targets. The materialized files +// exist to satisfy Metro's crawl, cache hashing, and worker pre-reads; the BYTES that +// get transformed still come from the bundle, hash-verified, via the transformer. +// (Capture with `--fs=async --child-process` so the manifests/configs Metro reads +// through fs -- package.json, babel.config.js -- are attested and materialize too.) +// // WHAT THIS DOES AND DOESN'T GUARANTEE: // - A recorded edge is served AS RECORDED: Metro's probing (platform suffixes, // package.json fields, Haste) is short-circuited for it, so resolution can't drift @@ -81,6 +109,16 @@ function getLoadState() { stateInited = true const state = State.preload ?? new State(process.cwd()) loadState = state.config.loadBundle ? state : null + // A misconfigured resolver fails SILENT (every request defers to Metro, which then + // probes disk and produces its own UnableToResolveError) -- announce the resolved + // mode once under EXODUS_STASIS_DEBUG so "wired but inert" is diagnosable: a stale + // capture (the attested config predates the wiring), a root/cwd mismatch, or a + // non-load mode all show up right here. + if (state.config.debug) { + console.warn(loadState + ? `[stasis] StasisMetroResolver: load mode active -- root=${loadState.root}, bundleFile=${loadState.config.bundleFile ?? ''}, scope=${loadState.config.full ? 'full' : 'node_modules'}` + : '[stasis] StasisMetroResolver: not in load mode -- passing every request through') + } } return loadState } @@ -130,8 +168,18 @@ export function createResolveRequest(base = undefined) { // `stasis bundle --metro/--mainFields` artifact) maps to Metro's own notion // of an empty resolution -- Metro then never tries to read the synthetic // path from disk, which only the bundle carries. - if (state.relative(target) === EMPTY_MODULE_PATH) return { type: 'empty' } - return { type: 'sourceFile', filePath: target } + const empty = state.relative(target) === EMPTY_MODULE_PATH + if (state.config.debug) { + console.warn(`[stasis] StasisMetroResolver: '${moduleName}' from ${origin} -> ${empty ? '' : target}`) + } + return empty ? { type: 'empty' } : { type: 'sourceFile', filePath: target } + } + // An in-scope origin with no recorded edge: the request Metro is about to + // re-derive from disk. Under debug, name it -- when the disk probe then fails + // (UnableToResolveError), this line is the difference between "the bundle + // never recorded the edge" (stale/static capture) and "the resolver never ran". + if (state.config.debug) { + console.warn(`[stasis] StasisMetroResolver: no recorded edge for '${moduleName}' from ${origin}${typeof platform === 'string' ? ` (platform=${platform})` : ''} -- deferring to Metro`) } } } diff --git a/stasis-plugins/src/metro-transformer.js b/stasis-plugins/src/metro-transformer.js index 48b7f6c3..ffc71384 100644 --- a/stasis-plugins/src/metro-transformer.js +++ b/stasis-plugins/src/metro-transformer.js @@ -52,7 +52,9 @@ import { State } from '@exodus/stasis-core/state' // processes (stasis --mock territory), which is larger than this seam. So today's // guarantee is "build the bundle's attested bytes, fail closed on disk drift," not // "build from a bundle with no source tree." This mirrors the documented -// node_modules-crossing gap in the webpack load path. +// node_modules-crossing gap in the webpack load path. To START from no source tree, +// materialize the attested files first (`stasis extract`) and build there -- the +// supported flow, spelled out in ./metro-resolver.js. const require = createRequire(import.meta.url) diff --git a/stasis/src/cmd/extract.js b/stasis/src/cmd/extract.js index 76197f1e..d249cdfc 100644 --- a/stasis/src/cmd/extract.js +++ b/stasis/src/cmd/extract.js @@ -80,11 +80,14 @@ export function lockfileFromBundle(bundle) { // disk: write every bundled source to `output` (default: cwd), preserving its // project-relative path, and drop a matching `stasis.lock.json` alongside them. // The lockfile is derived from the bundle's own contents, so the extracted tree -// validates against it out of the box (e.g. `stasis prune`). For legacy v0 -// bundles only the sources are extracted -- see the in-body comment. +// validates against it out of the box (e.g. `stasis prune`). When the bundle +// attests no root package.json, a minimal one is synthesized from the workspace +// bucket's identity so the tree is usable as a stasis root (see the in-body +// comment). For legacy v0 bundles only the sources are extracted -- see the +// in-body comment. // // Returns `{ dir, files }` where `files` is the number of sources written -// (the lockfile is not counted). +// (the lockfile and a synthesized package.json are not counted). export function extractCommand({ cwd = process.cwd(), bundleFile, output } = {}) { if (!bundleFile) throw new Error('extract: a bundle file is required') const bundleAbs = resolve(cwd, bundleFile) @@ -185,7 +188,29 @@ export function extractCommand({ cwd = process.cwd(), bundleFile, output } = {}) mkdirSync(outDir, { recursive: true }) // for an empty bundle, where no write created it if (withLockfile) writeFileSync(lockAbs, lockText) + // A State constructed in the extracted tree (`stasis run --bundle=load` there -- the + // no-source-tree Metro flow) must root itself here, and State's root discovery REFUSES + // a directory holding a stasis artifact (the derived stasis.lock.json just written) + // with no package.json. The bundle carries the root manifest only when it was attested + // (loaded as a module, or captured as an --fs read); when it wasn't, synthesize a + // MINIMAL one from the workspace bucket's attested identity -- the same basis + // `stasis prune` rewrites manifests from (the artifact is the attested source of + // package identity). Written only when absent AFTER the planned writes: an attested + // package.json has already landed by now, and extracting into an existing project + // keeps its manifest (existsSync also covers a pathological package.json directory). + const pkgAbs = join(outDir, 'package.json') + let syntheticPkg = false + if (withLockfile && !existsSync(pkgAbs)) { + const workspace = bundle.modules.get('.') + const identity = { name: workspace?.name ?? 'stasis-extracted', version: workspace?.version ?? '0.0.0' } + writeFileSync(pkgAbs, `${JSON.stringify(identity, null, 2)}\n`) + syntheticPkg = true + } + console.warn(`[stasis] Extracted ${writes.length} file(s)${withLockfile ? ` and ${FILE_LOCK}` : ''} to ${outDir}`) + if (syntheticPkg) { + console.warn('[stasis] Bundle carries no root package.json; wrote a minimal one (identity from the workspace bucket) so the extracted tree roots correctly') + } if (!withLockfile) { console.warn(`[stasis] Warning: legacy v0 bundle records no package name/version, so ${FILE_LOCK} can not be restored and was not written`) } diff --git a/tests/metro-resolver.test.js b/tests/metro-resolver.test.js index bc70e3c0..928b6f61 100644 --- a/tests/metro-resolver.test.js +++ b/tests/metro-resolver.test.js @@ -11,7 +11,7 @@ import { test } from 'node:test' import { spawnSync } from 'node:child_process' -import { cpSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -21,6 +21,8 @@ const here = dirname(fileURLToPath(import.meta.url)) const cli = join(here, '..', 'stasis', 'bin', 'stasis.js') const captureHelper = join(here, 'metro-run.helper.js') const resolverHelper = join(here, 'metro-resolver-run.helper.js') +const transformHelper = join(here, 'metro-transformer-run.helper.js') +const mockBase = join(here, 'metro-mock-transformer.cjs') const fullFixture = join(here, 'fixtures', 'metro-full') const nmFixture = join(here, 'fixtures', 'metro-nm') const fieldsFixture = join(here, 'fixtures', 'resolve-fields') @@ -220,6 +222,75 @@ test('a --metro multi-platform artifact serves per-platform edges by the platfor t.assert.deepEqual(empty, { served: { type: 'empty' } }) })) +test('no-source-tree flow: extract materializes the attested tree, recorded edges bridge unattested layouts, bundle bytes feed the transform', withTmp((t, tmp) => { + // The @babel/runtime/helpers shape: the PUBLIC specifier layout ('fakepkg/helpers/x') + // differs from the file resolution lands on ('build/x.js' -- a package.json redirect at + // capture time). Only the TARGET is ever loaded, so only it is attested. + const proj = join(tmp, 'proj') + mkdirSync(join(proj, 'src'), { recursive: true }) + mkdirSync(join(proj, 'node_modules', 'fakepkg', 'build'), { recursive: true }) + writeFileSync(join(proj, 'package.json'), JSON.stringify({ name: 'hermetic-fixture', version: '1.0.0' })) + writeFileSync(join(proj, 'src', 'entry.js'), "require('fakepkg/helpers/x')\n") + writeFileSync(join(proj, 'node_modules', 'fakepkg', 'package.json'), JSON.stringify({ name: 'fakepkg', version: '1.0.0' })) + writeFileSync(join(proj, 'node_modules', 'fakepkg', 'build', 'x.js'), 'module.exports = 1\n') + + const bundle = join(tmp, 'app.br') // outside proj: it must survive the wipe below + const cap = capture('src/entry.js', { + cwd: proj, + graph: { + modules: [ + { path: 'src/entry.js', deps: [['fakepkg/helpers/x', 'node_modules/fakepkg/build/x.js']] }, + { path: 'node_modules/fakepkg/build/x.js', deps: [] }, + ], + }, + env: { + EXODUS_STASIS_LOCK: 'add', EXODUS_STASIS_SCOPE: 'full', + EXODUS_STASIS_BUNDLE: 'add', EXODUS_STASIS_BUNDLE_FILE: bundle, + }, + }) + t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) + + // The starting state the flow must work from: nothing on disk but the artifact. + rmSync(proj, { recursive: true, force: true }) + + const work = join(tmp, 'work') + const x = runCli(['extract', `--output=${work}`, bundle], { cwd: tmp }) + t.assert.equal(x.status, 0, `extract stderr: ${x.stderr}`) + // The attested target materialized (Metro's crawl/hash/worker-read will find it); + // the alias layout was never attested, so it doesn't exist -- Metro's own probing + // would die on it, which is exactly what the recorded edge bridges. + t.assert.ok(existsSync(join(work, 'node_modules', 'fakepkg', 'build', 'x.js'))) + t.assert.ok(!existsSync(join(work, 'node_modules', 'fakepkg', 'helpers'))) + t.assert.ok(existsSync(join(work, 'stasis.lock.json')), 'extract derives the lockfile for frozen verification') + // The bundle attests no root manifest (serializer captures never load it), so extract + // synthesizes one from the workspace bucket's identity -- without it, State's root + // discovery refuses the tree ('Unexpected stasis config without package.json'). + t.assert.deepEqual(JSON.parse(readFileSync(join(work, 'package.json'), 'utf-8')), { name: 'hermetic-fixture', version: '1.0.0' }) + + const env = { + EXODUS_STASIS_BUNDLE: 'load', EXODUS_STASIS_BUNDLE_FILE: bundle, + EXODUS_STASIS_LOCK: 'frozen', EXODUS_STASIS_SCOPE: 'full', + EXODUS_STASIS_DEBUG: '1', + } + const r = resolveAll([{ origin: 'src/entry.js', moduleName: 'fakepkg/helpers/x' }], { cwd: work, env }) + t.assert.equal(r.status, 0, `resolver stderr: ${r.stderr}`) + const [out] = JSON.parse(r.stdout) + t.assert.deepEqual(out, { served: { type: 'sourceFile', filePath: join(work, 'node_modules', 'fakepkg', 'build', 'x.js') } }) + // The debug channel names the mode and the served edge -- the diagnosability story. + t.assert.match(r.stderr, /StasisMetroResolver: load mode active/) + t.assert.match(r.stderr, /'fakepkg\/helpers\/x' from .* -> .*build/) + + // And the bytes the transform consumes come from the bundle (hash-verified against + // the extracted lockfile), not from whatever the worker read off disk. + const tr = spawnSync(process.execPath, [transformHelper, 'node_modules/fakepkg/build/x.js'], { + encoding: 'utf-8', + cwd: work, + env: { ...cleanEnv, EXODUS_STASIS_METRO_BASE_TRANSFORMER: mockBase, STASIS_TEST_DISK_BYTES: 'TAMPERED', ...env }, + }) + t.assert.equal(tr.status, 0, `transform stderr: ${tr.stderr}`) + t.assert.equal(JSON.parse(tr.stdout)[0].received, 'module.exports = 1\n') +})) + test('createResolveRequest(base) defers misses to the base, not context.resolveRequest', withTmp((t, tmp) => { cpSync(fullFixture, tmp, { recursive: true }) const bundle = join(tmp, 'snapshot.br')