From 83ce2ef15c9fae6afbf39e93140462e532ff88d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:20:03 +0000 Subject: [PATCH 1/2] feat(plugins): add metro-resolve-request -- 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-resolve-request` -- 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's artifact-util as the shared writer/reader constant) is translated to Metro's native `{ type: 'empty' }` so it never touches disk. Covered by tests/metro-resolve-request.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. Named metro-resolve-request (not metro-resolver) so it can't be confused with `stasis bundle --metro --metro-resolver`, which statically resolves through the project's own metro-resolver package (stasis/src/metro-resolver.js). 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/artifact-util.js | 5 + stasis-core/src/state.js | 9 +- stasis-plugins/package.json | 4 +- stasis-plugins/src/metro-resolve-request.js | 162 +++++++++++++ stasis-plugins/src/metro-transformer.js | 1 + stasis-plugins/src/metro.js | 6 +- stasis/package.json | 2 + stasis/src/cmd/bundle.js | 5 +- stasis/src/metro-resolve-request.js | 8 + tests/metro-resolve-request-run.helper.js | 58 +++++ tests/metro-resolve-request.test.js | 256 ++++++++++++++++++++ tests/public-exports.test.js | 11 + 12 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 stasis-plugins/src/metro-resolve-request.js create mode 100644 stasis/src/metro-resolve-request.js create mode 100644 tests/metro-resolve-request-run.helper.js create mode 100644 tests/metro-resolve-request.test.js diff --git a/stasis-core/src/artifact-util.js b/stasis-core/src/artifact-util.js index c66a4be6..dde0c6ed 100644 --- a/stasis-core/src/artifact-util.js +++ b/stasis-core/src/artifact-util.js @@ -24,6 +24,11 @@ export const KNOWN_FORMATS = new Set([ ...STAT_FORMATS, ]) +// The reserved path of the empty module a browser/react-native `false` redirect resolves to in a +// `stasis bundle --mainFields/--metro` artifact (carried as a real empty CJS file); shared by the +// writer and readers that map it back to their own notion of empty (metro-resolve-request). +export const EMPTY_MODULE_PATH = '.stasis/empty-module.js' + // Payload-free stat records: attest a path's KIND, no content, and yield to a real format. export const isStatFormat = (format) => STAT_FORMATS.has(format) diff --git a/stasis-core/src/state.js b/stasis-core/src/state.js index f5368331..b6a96b25 100644 --- a/stasis-core/src/state.js +++ b/stasis-core/src/state.js @@ -1306,19 +1306,20 @@ export class State { // Resolve a CJS require() target to a bundled absolute path, or undefined to defer to Node (the // hooks.js CJS shim needs this: registerHooks can't intercept Module._resolveFilename). Matches // under ANY conditions bucket, since native require gives none; divergent buckets -> defer. - resolveBundled(parentURL, specifier) { + // A per-platform { platform: file } Map (--metro) resolves only for a caller passing `platform` + // (the metro-resolve-request plugin); platform-less callers defer to native. + 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) } if (matches.size !== 1) return undefined const [only] = matches - // Per-platform { platform: file } Map (--metro) has no single path: defer to native, not resolve()'s TypeError. - if (typeof only !== 'string') return undefined return resolve(this.root, only) } diff --git a/stasis-plugins/package.json b/stasis-plugins/package.json index 5aa6c638..ff770009 100644 --- a/stasis-plugins/package.json +++ b/stasis-plugins/package.json @@ -8,12 +8,14 @@ "./esbuild": "./src/esbuild.js", "./rollup": "./src/rollup.js", "./metro": "./src/metro.js", - "./metro-transformer": "./src/metro-transformer.js" + "./metro-transformer": "./src/metro-transformer.js", + "./metro-resolve-request": "./src/metro-resolve-request.js" }, "files": [ "src/esbuild.js", "src/metro.js", "src/metro-transformer.js", + "src/metro-resolve-request.js", "src/plugins.js", "src/rollup.js", "src/webpack.js" diff --git a/stasis-plugins/src/metro-resolve-request.js b/stasis-plugins/src/metro-resolve-request.js new file mode 100644 index 00000000..d0c96697 --- /dev/null +++ b/stasis-plugins/src/metro-resolve-request.js @@ -0,0 +1,162 @@ +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-resolve-request') +// 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. +// A construction failure is cached and rethrown on every call, like the transformer's: never +// throw once and then silently pass through. +let stateInited = false +let loadState = null +let stateError = null +function getLoadState() { + if (!stateInited) { + try { + const state = State.preload ?? new State(process.cwd()) + loadState = state.config.loadBundle ? state : null + } catch (err) { + stateError = err + throw err + } finally { + stateInited = true + } + } + if (stateError) throw stateError + 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( + 'metro-resolve-request: 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 657066b9..b86bfa31 100644 --- a/stasis-plugins/src/metro-transformer.js +++ b/stasis-plugins/src/metro-transformer.js @@ -13,6 +13,7 @@ import { State } from '@exodus/stasis-core/state' // bundle's hash-verified bytes (fail-closed; out-of-scope files pass their disk bytes through). // KNOWN LIMITATION: does NOT build with sources absent from disk -- Metro reads + hashes each file // before this runs, so the guarantee is "build attested bytes, fail closed on disk drift." +// Recorded resolution edges are served by the companion ./metro-resolve-request.js. const require = createRequire(import.meta.url) diff --git a/stasis-plugins/src/metro.js b/stasis-plugins/src/metro.js index 6deb6567..9940e67a 100644 --- a/stasis-plugins/src/metro.js +++ b/stasis-plugins/src/metro.js @@ -112,8 +112,10 @@ function metroDefaultSerializer() { // Stasis capture plugin for Metro (React Native). Hooks Metro's serializer, which runs ONCE in // the MAIN process with the full module graph -- a per-worker transformer's process-local State // could never be merged. This is the CAPTURE half; the LOAD half is the companion -// ./metro-transformer.js. Wire both permanently (withStasis + transformerPath); the mode picks -// the active one, and under bundle=load this serializer is a transparent pass-through. +// ./metro-transformer.js (bytes) plus ./metro-resolve-request.js (recorded resolution edges, wired as +// resolver.resolveRequest). Wire all of them permanently (withStasis + transformerPath + +// resolveRequest); the mode picks the active ones, and under bundle=load this serializer is a +// transparent pass-through. // Capture is one-shot (dev-server rebuilds refused, see #run) and REQUIRES --child-process so the // worker-side toolchain (babel, RN preset) is attested (enforced in the constructor). withStasis // wires the stable customSerializer -- the only surface that receives preModules; serializerHook diff --git a/stasis/package.json b/stasis/package.json index 5882661c..c02d3f97 100644 --- a/stasis/package.json +++ b/stasis/package.json @@ -9,6 +9,7 @@ "./rollup": "./src/rollup.js", "./metro": "./src/metro.js", "./metro-transformer": "./src/metro-transformer.js", + "./metro-resolve-request": "./src/metro-resolve-request.js", "./loader": "./src/loader.js", "./mock": "./src/mock.js", "./bundle": "./src/bundle.js", @@ -51,6 +52,7 @@ "src/metro.js", "src/metro-resolver.js", "src/metro-transformer.js", + "src/metro-resolve-request.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 faa39b83..1d4329e4 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -14,7 +14,7 @@ import { State } from '@exodus/stasis-core/state' import { brotliOptions } from '@exodus/stasis-core/brotli' import { sha512integrity } from '@exodus/stasis-core/state-util' import { detectRepo, findPackageMetadata, normalizeEntries, packageType, readJson, readModuleManifest, readPackageJson, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util' -import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, posixPathEscapes, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' +import { EMPTY_MODULE_PATH, RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, posixPathEscapes, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' import { diskHost } from '@exodus/stasis-core/host' import { SOLIDITY_PACKAGE_MANIFESTS, @@ -768,9 +768,6 @@ const SOURCE_EXTS = ['js', 'json', 'ts'] const SOURCE_EXTS_JSX = ['js', 'jsx', 'json', 'ts', 'tsx'] // React Native preset mainFields for `--metro` (which also sets the RN conditions + platform suffixes). const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main'] -// Synthetic path for the empty module a browser/react-native `false` redirect resolves to, -// carried as a real empty CJS file so the edge points at attestable bytes. -const EMPTY_MODULE_PATH = '.stasis/empty-module.js' // Recursively collect files under a native ios/android dir, skipping build output and symlinks // (cycle/escape hazard). Absolute paths into `out`. diff --git a/stasis/src/metro-resolve-request.js b/stasis/src/metro-resolve-request.js new file mode 100644 index 00000000..253e53be --- /dev/null +++ b/stasis/src/metro-resolve-request.js @@ -0,0 +1,8 @@ +// Public-export adapter: backs `@exodus/stasis/metro-resolve-request`. 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-resolve-request' diff --git a/tests/metro-resolve-request-run.helper.js b/tests/metro-resolve-request-run.helper.js new file mode 100644 index 00000000..f00d5408 --- /dev/null +++ b/tests/metro-resolve-request-run.helper.js @@ -0,0 +1,58 @@ +// Drives the metro-resolve-request 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-resolve-request-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-resolve-request.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-resolve-request.test.js b/tests/metro-resolve-request.test.js new file mode 100644 index 00000000..6400b268 --- /dev/null +++ b/tests/metro-resolve-request.test.js @@ -0,0 +1,256 @@ +// End-to-end coverage for the metro-resolve-request (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-resolve-request-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-resolve-request-')) + 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 22a79a63..b36fe204 100644 --- a/tests/public-exports.test.js +++ b/tests/public-exports.test.js @@ -12,11 +12,13 @@ import { StasisWebpack } from '@exodus/stasis/webpack' import { StasisRollup } from '@exodus/stasis/rollup' import { StasisMetro } from '@exodus/stasis/metro' import * as metroTransformer from '@exodus/stasis/metro-transformer' +import * as metroResolveRequest from '@exodus/stasis/metro-resolve-request' import { StasisEsbuild as PluginsEsbuild } from '@exodus/stasis-plugins/esbuild' import { StasisWebpack as PluginsWebpack } from '@exodus/stasis-plugins/webpack' import { StasisRollup as PluginsRollup } from '@exodus/stasis-plugins/rollup' import { StasisMetro as PluginsMetro } from '@exodus/stasis-plugins/metro' import * as pluginsMetroTransformer from '@exodus/stasis-plugins/metro-transformer' +import * as pluginsMetroResolveRequest from '@exodus/stasis-plugins/metro-resolve-request' test('@exodus/stasis/bundle exports Bundle class', (t) => { t.assert.equal(typeof Bundle, 'function') @@ -41,6 +43,15 @@ test('@exodus/stasis/metro-transformer re-exports the stasis-plugins worker tran t.assert.equal(metroTransformer.getCacheKey, pluginsMetroTransformer.getCacheKey) }) +test('@exodus/stasis/metro-resolve-request re-exports the stasis-plugins resolveRequest plugin', (t) => { + t.assert.equal(typeof metroResolveRequest.resolveRequest, 'function') + t.assert.equal(typeof metroResolveRequest.createResolveRequest, 'function') + t.assert.equal(metroResolveRequest.resolveRequest, pluginsMetroResolveRequest.resolveRequest) + t.assert.equal(metroResolveRequest.createResolveRequest, pluginsMetroResolveRequest.createResolveRequest) + // createResolveRequest validates its optional base up front. + t.assert.throws(() => metroResolveRequest.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 5f2eb4c55a098256cdeee0970b534cfe2b2dda8d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:22:07 +0000 Subject: [PATCH 2/2] feat(metro-resolve-request): 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 (stasis-core/src/extract.js; a capture with --package-json already carries the root manifest, so this only fires without it). 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 | 5 ++ stasis-core/src/extract.js | 10 +++ stasis-plugins/src/metro-resolve-request.js | 49 +++++++++++++- stasis-plugins/src/metro-transformer.js | 3 +- tests/metro-resolve-request.test.js | 73 ++++++++++++++++++++- 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/doc/extract.md b/doc/extract.md index 0f588cf6..fdf2dea7 100644 --- a/doc/extract.md +++ b/doc/extract.md @@ -30,6 +30,11 @@ across verbatim. The extracted tree validates out of the box — `stasis prune` works directly against it; `stasis run --lock=frozen` additionally needs the project's `package.json` files, present only if the bundle recorded them. +When the bundle attests no root `package.json` (a bundler-plugin capture without +`--package-json`), a minimal `{ name, version }` one is synthesized from the workspace +bucket's identity so the tree can root a `stasis run`; an extracted or pre-existing +`package.json` is never overwritten. + Legacy `version: 0` bundles record no `name`/`version`, so no lockfile can be restored: sources are still extracted, the lockfile is skipped with a warning. diff --git a/stasis-core/src/extract.js b/stasis-core/src/extract.js index c11d9a84..6d809243 100644 --- a/stasis-core/src/extract.js +++ b/stasis-core/src/extract.js @@ -146,9 +146,19 @@ export function extractCommand({ cwd = process.cwd(), bundleFile, output, logLab } mkdirSync(outDir, { recursive: true }) // for an empty bundle, where no write created it if (withLockfile) writeFileSync(lockAbs, lockText) + // State's root discovery refuses a dir holding stasis.lock.json without a package.json, so give a + // tree whose bundle attests no root manifest (a capture without --package-json) a minimal one from + // the workspace bucket's attested identity. Never overwrites an extracted or pre-existing one. + const pkgAbs = join(outDir, 'package.json') + const syntheticPkg = withLockfile && !existsSync(pkgAbs) + if (syntheticPkg) { + const workspace = bundle.modules.get('.') + writeFileSync(pkgAbs, `${JSON.stringify({ name: workspace?.name ?? 'stasis-extracted', version: workspace?.version ?? '0.0.0' }, null, 2)}\n`) + } const execNote = executables > 0 ? ` (${executables} executable)` : '' console.warn(`[${logLabel}] Extracted ${writes.length} file(s)${execNote}${withLockfile ? ` and ${FILE_LOCK}` : ''} to ${outDir}`) + if (syntheticPkg) console.warn(`[${logLabel}] Bundle carries no root package.json; wrote a minimal one so the tree roots correctly`) if (chmodFailures > 0) { console.warn(`[${logLabel}] Warning: could not set file modes on ${chmodFailures} file(s) (the filesystem may not support them); contents were written`) } diff --git a/stasis-plugins/src/metro-resolve-request.js b/stasis-plugins/src/metro-resolve-request.js index d0c96697..88e2bba6 100644 --- a/stasis-plugins/src/metro-resolve-request.js +++ b/stasis-plugins/src/metro-resolve-request.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 @@ -84,6 +112,13 @@ function getLoadState() { try { 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 raises its own UnableToResolveError), so name the resolved mode once under debug. + if (state.config.debug) { + console.warn(loadState + ? `[stasis] metro-resolve-request: load mode active -- root=${loadState.root}, bundleFile=${loadState.config.bundleFile ?? ''}, scope=${loadState.config.full ? 'full' : 'node_modules'}` + : '[stasis] metro-resolve-request: not in load mode -- passing every request through') + } } catch (err) { stateError = err throw err @@ -140,8 +175,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] metro-resolve-request: '${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] metro-resolve-request: 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 b86bfa31..311bfc50 100644 --- a/stasis-plugins/src/metro-transformer.js +++ b/stasis-plugins/src/metro-transformer.js @@ -13,7 +13,8 @@ import { State } from '@exodus/stasis-core/state' // bundle's hash-verified bytes (fail-closed; out-of-scope files pass their disk bytes through). // KNOWN LIMITATION: does NOT build with sources absent from disk -- Metro reads + hashes each file // before this runs, so the guarantee is "build attested bytes, fail closed on disk drift." -// Recorded resolution edges are served by the companion ./metro-resolve-request.js. +// Recorded resolution edges are served by the companion ./metro-resolve-request.js; to start from +// no source tree, `stasis extract` the artifact and build there (see that file). const require = createRequire(import.meta.url) diff --git a/tests/metro-resolve-request.test.js b/tests/metro-resolve-request.test.js index 6400b268..c36788ec 100644 --- a/tests/metro-resolve-request.test.js +++ b/tests/metro-resolve-request.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-resolve-request-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, /metro-resolve-request: 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')