Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions doc/extract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions stasis-core/src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<platform, target> 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
Expand Down
9 changes: 9 additions & 0 deletions stasis-core/src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion stasis-plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
200 changes: 200 additions & 0 deletions stasis-plugins/src/metro-resolver.js
Original file line number Diff line number Diff line change
@@ -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
// `<nm>/pkg/helpers/x(.js|.<platform>.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 \
// -- <metro build cmd> # 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 ?? '<default>'}, 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 ? '<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()
Loading