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/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/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-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..88e2bba6 --- /dev/null +++ b/stasis-plugins/src/metro-resolve-request.js @@ -0,0 +1,207 @@ +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. +// +// 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 +// 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 + // 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 + } 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. + 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`) + } + } + } + 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..311bfc50 100644 --- a/stasis-plugins/src/metro-transformer.js +++ b/stasis-plugins/src/metro-transformer.js @@ -13,6 +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; 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/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..c36788ec --- /dev/null +++ b/tests/metro-resolve-request.test.js @@ -0,0 +1,327 @@ +// 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, 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' +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 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') + +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('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') + 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')