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