@exodus/stasis-plugins 1.0.0-beta.11 → 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/stasis-plugins",
3
- "version": "1.0.0-beta.11",
3
+ "version": "1.0.0-beta.13",
4
4
  "description": "Bundler plugins (webpack, esbuild, Rollup, Metro) for @exodus/stasis-core",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,11 +8,14 @@
8
8
  "./esbuild": "./src/esbuild.js",
9
9
  "./rollup": "./src/rollup.js",
10
10
  "./metro": "./src/metro.js",
11
- "./metro-transformer": "./src/metro-transformer.js"
11
+ "./metro-transformer": "./src/metro-transformer.js",
12
+ "./metro-native": "./src/metro-native.js"
12
13
  },
13
14
  "files": [
14
15
  "src/esbuild.js",
15
16
  "src/metro.js",
17
+ "src/metro-babel-transformer.js",
18
+ "src/metro-native.js",
16
19
  "src/metro-transformer.js",
17
20
  "src/plugins.js",
18
21
  "src/rollup.js",
@@ -33,6 +36,6 @@
33
36
  },
34
37
  "homepage": "https://github.com/PreventiveMeasures/stasis#readme",
35
38
  "dependencies": {
36
- "@exodus/stasis-core": "1.0.0-beta.11"
39
+ "@exodus/stasis-core": "1.0.0-beta.13"
37
40
  }
38
41
  }
@@ -0,0 +1,109 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+ import { createRequire } from 'node:module'
4
+
5
+ // Stands in for the project's Babel transformer (`babelTransformerPath`) under ./metro-transformer.js,
6
+ // which swaps it in for each worker transform, to keep each import as WRITTEN in the source. Metro
7
+ // names a dependency by the specifier Babel leaves in its output, so a plugin rewriting it (e.g.
8
+ // babel-plugin-module-resolver mapping `#local/abc` to `./src/abc`) leaves the serializer only the
9
+ // rewrite. This adds one plugin that records every specifier in its `pre` -- Babel runs every
10
+ // plugin's `pre` before any plugin's visitor, so nothing has rewritten one yet -- and reads each
11
+ // again once Babel is done, before Metro's own passes replace them in the AST, unless Babel erased
12
+ // it. The real transformer runs as it would; its output is untouched.
13
+
14
+ const require = createRequire(import.meta.url)
15
+
16
+ // One per worker transform, run by ./metro-transformer.js: `babelTransformerPath` is the project's
17
+ // (absolute), and `asWritten` maps each specifier Babel left to the ones written there.
18
+ export const transformContext = new AsyncLocalStorage()
19
+
20
+ // The nodes Metro's collectDependencies takes a specifier from: these declarations, and the calls
21
+ // below (`import()`, `require()`, `__prefetchImport()`, `require.<member>()`).
22
+ const DECLARATIONS = new Set(['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'])
23
+ const REQUIRE_MEMBERS = new Set(['context', 'resolveWeak', 'unstable_importMaybeSync'])
24
+
25
+ function isDependencyCall(callee) {
26
+ if (callee.type === 'Import') return true
27
+ if (callee.type === 'Identifier') return callee.name === 'require' || callee.name === '__prefetchImport'
28
+ return callee.type === 'MemberExpression' && !callee.computed &&
29
+ callee.object.type === 'Identifier' && callee.object.name === 'require' &&
30
+ callee.property.type === 'Identifier' && REQUIRE_MEMBERS.has(callee.property.name)
31
+ }
32
+
33
+ // The string `node` holds as a constant: a literal, a template with no substitutions, or a `+` of
34
+ // such, which Metro's collectDependencies evaluates to the specifier as it does a literal.
35
+ function constantString(node) {
36
+ if (node?.type === 'StringLiteral') return node.value
37
+ if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked ?? undefined
38
+ if (node?.type !== 'BinaryExpression' || node.operator !== '+') return undefined
39
+ const left = constantString(node.left)
40
+ const right = left === undefined ? undefined : constantString(node.right)
41
+ return right === undefined ? undefined : left + right
42
+ }
43
+
44
+ // The specifier a dependency node holds as a constant: an import/export's source, a call's first
45
+ // argument. A plugin rewriting it replaces the literal, never the node holding it.
46
+ function specifierOf(node) {
47
+ return constantString(node.type === 'CallExpression' ? node.arguments[0] : node.source)
48
+ }
49
+
50
+ function asWrittenPlugin({ types: t }) {
51
+ return {
52
+ name: 'stasis-as-written',
53
+ pre(file) {
54
+ const { slots } = transformContext.getStore()
55
+ let evaluate = false
56
+ t.traverseFast(file.ast, (node) => {
57
+ if (node.type === 'CallExpression' ? !isDependencyCall(node.callee) : !DECLARATIONS.has(node.type)) return
58
+ const written = specifierOf(node)
59
+ if (written !== undefined) slots.push({ node, written })
60
+ else if (node.type === 'CallExpression' && node.arguments.length > 0) evaluate = true
61
+ })
62
+ // Metro takes any argument Babel evaluates to a string with confidence, one bound to a constant
63
+ // too (`const name = './x'; require(name)`), which only a path, and its scope, can evaluate.
64
+ if (!evaluate) return
65
+ file.path.traverse({
66
+ CallExpression(path) {
67
+ if (!isDependencyCall(path.node.callee) || specifierOf(path.node) !== undefined || path.node.arguments.length === 0) return
68
+ const { confident, value } = path.get('arguments.0').evaluate()
69
+ if (confident && typeof value === 'string') slots.push({ node: path.node, written: value })
70
+ },
71
+ })
72
+ },
73
+ visitor: {
74
+ // Babel exits a node only if no plugin removed it on the way in, so a declaration erased for
75
+ // importing types alone (`import type`, `import { type A }`, a TS import used only as a type)
76
+ // never exits: flow-strip-types removes it as it enters it, the TS plugin before the
77
+ // traversal gets there. One lowered to a require exits first: wherever it is listed,
78
+ // @babel/plugin-transform-modules-commonjs takes the declarations away as the program exits.
79
+ 'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': {
80
+ exit(path) {
81
+ transformContext.getStore().kept.add(path.node)
82
+ },
83
+ },
84
+ },
85
+ }
86
+ }
87
+
88
+ export function transform(args) {
89
+ const context = transformContext.getStore()
90
+ assert.ok(context, 'StasisMetro: the stasis Babel transformer runs only under the stasis metro-transformer')
91
+ const slots = []
92
+ const kept = new Set()
93
+ Object.assign(context, { slots, kept })
94
+ // Once Babel is done: each specifier as it left it, from the node that held it as written.
95
+ const collect = (result) => {
96
+ for (const { node, written } of slots) {
97
+ if (DECLARATIONS.has(node.type) && !kept.has(node)) continue
98
+ // One evaluated through its scope stays as written: no plugin rewrites what isn't a literal.
99
+ const left = specifierOf(node) ?? (node.type === 'CallExpression' ? written : undefined)
100
+ if (left === undefined) continue
101
+ if (!context.asWritten.has(left)) context.asWritten.set(left, new Set())
102
+ context.asWritten.get(left).add(written)
103
+ }
104
+ return result
105
+ }
106
+ // Sync or async, as the project's transformer is (the RN one is sync).
107
+ const result = require(context.babelTransformerPath).transform({ ...args, plugins: [...(args.plugins ?? []), asWrittenPlugin] })
108
+ return typeof result?.then === 'function' ? result.then(collect) : collect(result)
109
+ }
@@ -0,0 +1,99 @@
1
+ import { dirname, join } from 'node:path'
2
+
3
+ import { classifyNativeCapture, isExcludedNativeDir, isNativeArtifact, isPodspec, isSkippedNativeWalkDir, refineNativeCapture } from '@exodus/stasis-core/util'
4
+
5
+ // The native build inputs of a React Native app's packages, shared by the Metro plugin's capture and
6
+ // `stasis bundle --metro` so the two carry the same files. Both read through `fs`:
7
+ // readdir(dir) -> Dirent[] (throws where `dir` isn't one), readFile(file) -> Buffer,
8
+ // stat(path) -> Stats, or null where nothing is there.
9
+
10
+ // True if the package at `root` ships native code: an ios/ or android/ dir, or a podspec under it.
11
+ export function looksNative(root, fs) {
12
+ if (fs.stat(join(root, 'ios')) !== null || fs.stat(join(root, 'android')) !== null) return true
13
+ return hasPodspec(root, fs)
14
+ }
15
+
16
+ // True if any *.podspec exists under `dir`, pruning the same subtrees the native walk skips.
17
+ function hasPodspec(dir, fs) {
18
+ let entries
19
+ try {
20
+ entries = fs.readdir(dir)
21
+ } catch {
22
+ return false
23
+ }
24
+ for (const ent of entries) {
25
+ if (ent.isSymbolicLink()) continue
26
+ if (ent.isFile()) {
27
+ if (isPodspec(ent.name)) return true
28
+ } else if (ent.isDirectory() && !isSkippedNativeWalkDir(ent.name)) {
29
+ if (hasPodspec(join(dir, ent.name), fs)) return true
30
+ }
31
+ }
32
+ return false
33
+ }
34
+
35
+ // Each native build input in the package at `root`, walked whole, a native build reading sources
36
+ // from anywhere in it (a podspec compiling `cpp/` beside `ios/`): every file but build output and
37
+ // prebuilt binaries (isSkippedNativeWalkDir, isNativeArtifact), an off-platform top-level dir
38
+ // (isExcludedNativeDir), what the shared classifier skips (the JS and TS Metro's graph owns, docs,
39
+ // secrets) and symlinks (a cycle or escape hazard). A file `skip(file)` holds is passed over unread.
40
+ // `resources` is the allowlist the byte-level rules consult. -> yields { file, source, action, format },
41
+ // `action` 'code' (under the source-language `format`) or 'resource'
42
+ export function* nativeInputs(root, fs, { resources = new Set(), skip = () => false } = {}) {
43
+ yield* walk(root, true)
44
+ function* walk(dir, atRoot) {
45
+ let entries
46
+ try {
47
+ entries = fs.readdir(dir)
48
+ } catch {
49
+ return // dir missing / not a directory -- nothing to walk
50
+ }
51
+ for (const ent of entries) {
52
+ if (ent.isSymbolicLink()) continue
53
+ const file = join(dir, ent.name)
54
+ if (ent.isDirectory()) {
55
+ if (!isSkippedNativeWalkDir(ent.name) && !(atRoot && isExcludedNativeDir(ent.name))) yield* walk(file, false)
56
+ continue
57
+ }
58
+ if (!ent.isFile() || isNativeArtifact(ent.name) || skip(file)) continue
59
+ const byName = classifyNativeCapture(ent.name)
60
+ if (byName.action === 'skip') continue
61
+ const source = fs.readFile(file)
62
+ // Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
63
+ const { action, format } = refineNativeCapture(byName, ent.name, source, resources)
64
+ if (action !== 'skip') yield { file, source, action, format }
65
+ }
66
+ }
67
+ }
68
+
69
+ // A package name as npm takes one (`name` or `@scope/name`), which a path can't climb out with.
70
+ const PACKAGE_NAME = /^(?:@[^/\\@.][^/\\]*\/)?[^/\\@.][^/\\]*$/u
71
+
72
+ // The app's packages `react-native config` links, as a native build would, read as data rather than
73
+ // by running the CLI: each name the app's package.json lists under dependencies, peerDependencies or
74
+ // devDependencies (the CLI's list), in the nearest node_modules up from `projectDir` that holds it
75
+ // (as the CLI looks), where it ships native code (looksNative). What a react-native.config.js changes,
76
+ // being code, isn't seen. -> the package dirs, as found
77
+ export function linkedNativePackages(projectDir, fs) {
78
+ let manifest
79
+ try {
80
+ manifest = JSON.parse(fs.readFile(join(projectDir, 'package.json')).toString('utf8'))
81
+ } catch {
82
+ return []
83
+ }
84
+ const listed = (field) => (manifest?.[field] && typeof manifest[field] === 'object' ? Object.keys(manifest[field]) : [])
85
+ const names = new Set([...listed('dependencies'), ...listed('peerDependencies'), ...listed('devDependencies')])
86
+ const dirs = []
87
+ for (const name of names) {
88
+ if (!PACKAGE_NAME.test(name)) continue
89
+ for (let dir = projectDir; ; dir = dirname(dir)) {
90
+ const found = join(dir, 'node_modules', name)
91
+ if (fs.stat(found)?.isDirectory()) {
92
+ if (looksNative(found, fs)) dirs.push(found)
93
+ break
94
+ }
95
+ if (dirname(dir) === dir) break
96
+ }
97
+ }
98
+ return dirs
99
+ }
@@ -1,41 +1,60 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { createRequire } from 'node:module'
3
- import { resolve as resolvePath } from 'node:path'
4
- import { pathToFileURL } from 'node:url'
3
+ import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
5
 
6
6
  import { State } from '@exodus/stasis-core/state'
7
+ import { transformContext } from './metro-babel-transformer.js'
7
8
 
8
- // Companion to the StasisMetro serializer: the LOAD half of stasis for Metro. Load only READS an
9
- // immutable bundle and every worker reads the same bytes (no IPC), so a per-worker transformer is
10
- // the natural seam. Wire it permanently in metro.config.js (transformerPath); outside load mode it
11
- // is a transparent pass-through, and under load the serializer is too -- the mode picks the active
12
- // half. In load mode, wrap the user's real transformer and replace Metro's on-disk `data` with the
13
- // bundle's hash-verified bytes (fail-closed; out-of-scope files pass their disk bytes through).
14
- // KNOWN LIMITATION: does NOT build with sources absent from disk -- Metro reads + hashes each file
15
- // before this runs, so the guarantee is "build attested bytes, fail closed on disk drift."
9
+ // Companion to the StasisMetro serializer, which withStasis() wires as Metro's `transformerPath`
10
+ // whenever stasis attests the build (wire it yourself only alongside a hand-wired serializer). It
11
+ // wraps the project's own transformer, and does two things in each worker:
12
+ // - keeps each import as written for the serializer, through the Babel transformer it swaps in
13
+ // (./metro-babel-transformer.js): a dependency Babel rewrote (e.g. through
14
+ // babel-plugin-module-resolver) carries the specifiers written in the source as `stasisAsWritten`;
15
+ // - is the LOAD half of stasis for Metro (under load the serializer only asserts edges). Load only
16
+ // READS an immutable bundle and every worker reads the same bytes (no IPC), so a per-worker
17
+ // transformer is the natural seam: it replaces Metro's on-disk `data` with the bundle's
18
+ // hash-verified bytes (fail-closed; out-of-scope files pass their disk bytes through).
19
+ // KNOWN LIMITATION: does NOT build with sources absent from disk -- Metro reads + hashes each
20
+ // file before this runs, so the guarantee is "build attested bytes, fail closed on disk drift."
16
21
 
17
22
  const require = createRequire(import.meta.url)
18
23
 
19
- // The real transformer we wrap (default matches Metro's; override for a custom TS/Babel preset).
20
- // Required lazily per worker so a non-load build pays nothing.
21
- const BASE_TRANSFORMER = process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker'
24
+ // The project's own transformer, which this one runs: the `transformerPath` withStasis() replaced,
25
+ // handed over in Metro's transformer config, else EXODUS_STASIS_METRO_BASE_TRANSFORMER, else
26
+ // Metro's default. Metro requires a name from its own dir, which only the project's node_modules
27
+ // is sure to reach, so a name resolves from the project root, then from here. A base that is this
28
+ // transformer itself (wired by hand as well) is passed over for Metro's default.
29
+ const bases = new Map()
30
+ function baseOf(config, projectRoot = process.cwd()) {
31
+ const path = config?.stasisBaseTransformerPath ?? (process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker')
32
+ const key = `${projectRoot}\0${path}`
33
+ if (!bases.has(key)) {
34
+ const resolve = (name) => require.resolve(name, { paths: [projectRoot, dirname(fileURLToPath(import.meta.url))] })
35
+ let file = resolve(path)
36
+ if (require(file).transform === transform) file = resolve('metro-transform-worker')
37
+ bases.set(key, { file, transformer: require(file) })
38
+ }
39
+ return bases.get(key)
40
+ }
22
41
 
23
- let base
24
- function getBase() {
25
- base ??= require(BASE_TRANSFORMER)
26
- return base
42
+ // The config the project's transformer sees: its own, without what withStasis() handed over.
43
+ function baseConfig({ stasisBaseTransformerPath: _, ...config } = {}) {
44
+ return config
27
45
  }
28
46
 
29
47
  // Per-worker load State, built once from the usual env + stasis.config.json. Non-load mode -> null
30
48
  // -> transparent pass-through, so wiring it permanently is safe. A construction failure is cached
31
- // and rethrown on every call -- never throw-once-then-silently-pass-through.
49
+ // and rethrown on every call -- never throw-once-then-silently-pass-through. It never writes, so it
50
+ // claims no write path: a capturing worker's own preload State already holds them.
32
51
  let stateInited = false
33
52
  let loadState = null
34
53
  let stateError = null
35
54
  function getLoadState() {
36
55
  if (!stateInited) {
37
56
  try {
38
- const state = new State(process.cwd())
57
+ const state = new State(process.cwd(), { claim: false })
39
58
  loadState = state.config.loadBundle ? state : null
40
59
  } catch (err) {
41
60
  stateError = err
@@ -60,7 +79,35 @@ function inScope(state, absolute) {
60
79
  return state.inNodeModules(pathToFileURL(absolute).toString())
61
80
  }
62
81
 
63
- export function transform(config, projectRoot, filename, data, options) {
82
+ // The Babel transformer swapped in for the project's, by path as metro-transform-worker requires it.
83
+ const BABEL_TRANSFORMER = fileURLToPath(new URL('./metro-babel-transformer.js', import.meta.url))
84
+
85
+ // The project's Babel transformer, absolute. Metro resolves a configured one; its default
86
+ // ('metro-babel-transformer') stays a name, which the base transformer requires from its own dir.
87
+ function resolveBabelTransformer(base, path) {
88
+ return isAbsolute(path) ? path : createRequire(base.file).resolve(path)
89
+ }
90
+
91
+ // Each dependency as the source wrote it. One named by a specifier Babel rewrote carries the ones
92
+ // written in the source: one Metro dependency can stand for several, written apart and rewritten
93
+ // alike (`#local/abc` and `./src/abc`). One no specifier in the source names carries
94
+ // `stasisInserted`: Babel added it (a helper from @babel/runtime, the JSX runtime), or Metro did (its
95
+ // asyncRequire for an `import()`, an asset's registry). A dependency written as Metro names it is
96
+ // left as is.
97
+ function withAsWritten(result, asWritten) {
98
+ if (!Array.isArray(result?.dependencies)) return result
99
+ const dependencies = result.dependencies.map((dep) => {
100
+ const written = asWritten.get(dep.name)
101
+ if (written === undefined) return { ...dep, stasisInserted: true }
102
+ if (written.size === 1 && written.has(dep.name)) return dep
103
+ return { ...dep, stasisAsWritten: [...written] }
104
+ })
105
+ return { ...result, dependencies }
106
+ }
107
+
108
+ export async function transform(config, projectRoot, filename, data, options) {
109
+ const base = baseOf(config, projectRoot)
110
+ config = baseConfig(config)
64
111
  const state = getLoadState()
65
112
  if (state) {
66
113
  // `filename` is project-relative in Metro's contract; resolve against projectRoot.
@@ -72,14 +119,19 @@ export function transform(config, projectRoot, filename, data, options) {
72
119
  data = Buffer.isBuffer(source) ? source : Buffer.from(source)
73
120
  }
74
121
  }
75
- return getBase().transform(config, projectRoot, filename, data, options)
122
+ if (typeof config.babelTransformerPath !== 'string') return base.transformer.transform(config, projectRoot, filename, data, options)
123
+ const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map() }
124
+ const result = await transformContext.run(context, () =>
125
+ base.transformer.transform({ ...config, babelTransformerPath: BABEL_TRANSFORMER }, projectRoot, filename, data, options))
126
+ return withAsWritten(result, context.asWritten)
76
127
  }
77
128
 
78
- export function getCacheKey(config) {
129
+ export function getCacheKey(config, opts) {
79
130
  // Fold in the base transformer's cache key, then add a stasis-load discriminator so load results
80
131
  // (which can differ from a disk build even when disk == bundle) don't cross-pollinate the cache.
81
- // Derive the marker from the resolved load State (not the env) so config.json activation keys too.
82
- const baseKey = typeof getBase().getCacheKey === 'function' ? getBase().getCacheKey(config) : ''
132
+ // Derive the marker from the resolved load State, which transform() goes by.
133
+ const { transformer } = baseOf(config, opts?.projectRoot)
134
+ const baseKey = typeof transformer.getCacheKey === 'function' ? transformer.getCacheKey(baseConfig(config), opts) : ''
83
135
  const state = getLoadState()
84
136
  const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'
85
137
  return `${baseKey}$stasis-metro-transformer:${marker}`
package/src/metro.js CHANGED
@@ -3,16 +3,28 @@ import { isUtf8 } from 'node:buffer'
3
3
  import { spawnSync } from 'node:child_process'
4
4
  import { existsSync } from 'node:fs'
5
5
  import { createRequire } from 'node:module'
6
- import { isAbsolute, join } from 'node:path'
7
- import { pathToFileURL } from 'node:url'
6
+ import { dirname, isAbsolute, join } from 'node:path'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
8
 
9
9
  import { resolvePluginState } from './plugins.js'
10
+ import { looksNative, nativeInputs } from './metro-native.js'
11
+ import { diskHost } from '@exodus/stasis-core/host'
10
12
  import { State } from '@exodus/stasis-core/state'
11
- import { realReadFileSync, realReaddirSync } from '@exodus/stasis-core/state-util'
12
- import { RN_CORE_INCLUDE_FILES, classifyExtension, classifyFormat, classifyNativeCapture, isExcludedNativeDir, isNativeArtifact, isPodspec, isSkippedNativeWalkDir, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util'
13
+ import { realReadFileSync } from '@exodus/stasis-core/state-util'
14
+ import { RN_CORE_INCLUDE_FILES, classifyExtension, classifyFormat, splitNodeModulesPath } from '@exodus/stasis-core/util'
13
15
 
14
16
  const require = createRequire(import.meta.url)
15
17
 
18
+ // The companion worker transformer withStasis() wires as Metro's `transformerPath`.
19
+ const TRANSFORMER = fileURLToPath(new URL('./metro-transformer.js', import.meta.url))
20
+
21
+ // A graph module's absolute path (graph modules and preModules both carry it), or undefined for a
22
+ // synthetic/virtual one (require.context's ?ctx=, __prelude__), with no on-disk bytes to hash.
23
+ function diskPath(module) {
24
+ const modPath = module?.path
25
+ return typeof modPath === 'string' && isAbsolute(modPath) && existsSync(modPath) ? modPath : undefined
26
+ }
27
+
16
28
  // Metro's `graph.entryPoints` is a Set now, an Array before ~0.71; normalize to a Set.
17
29
  function entrySet(graph) {
18
30
  const ep = graph?.entryPoints
@@ -28,6 +40,23 @@ const AUTO_INCLUDES = [
28
40
  '@react-native-community/cli/setup_env.sh',
29
41
  ]
30
42
 
43
+ // Metro's own transform plugins, as its transform worker names them for its cache key: the file
44
+ // `metro-transform-plugins` resolves to from the project, else through metro and its transform
45
+ // worker, which depend on it (a pnpm install links it only beside them). -> [] where none resolves
46
+ function metroTransformPluginFiles(projectDir) {
47
+ for (const chain of [['metro-transform-plugins'], ['metro', 'metro-transform-worker', 'metro-transform-plugins']]) {
48
+ let file
49
+ try {
50
+ for (const name of chain) file = require.resolve(name, { paths: [file === undefined ? projectDir : dirname(file)] })
51
+ } catch {
52
+ continue
53
+ }
54
+ const { getTransformPluginCacheKeyFiles } = require(file)
55
+ return typeof getTransformPluginCacheKeyFiles === 'function' ? getTransformPluginCacheKeyFiles() : []
56
+ }
57
+ return []
58
+ }
59
+
31
60
  // The worker-side toolchain that only Metro's transform workers load, named in both the
32
61
  // --child-process assert and the transform-cache warning so the two can't describe different sets.
33
62
  const WORKER_TOOLCHAIN = 'babel.config.js, @babel/core, the RN preset + plugins'
@@ -111,15 +140,16 @@ function metroDefaultSerializer() {
111
140
 
112
141
  // Stasis capture plugin for Metro (React Native). Hooks Metro's serializer, which runs ONCE in
113
142
  // the MAIN process with the full module graph -- a per-worker transformer's process-local State
114
- // could never be merged. This is the CAPTURE half; the LOAD half is the companion
115
- // ./metro-transformer.js. Wire both permanently (withStasis + transformerPath); the mode picks
116
- // the active one, and under bundle=load this serializer is a transparent pass-through.
143
+ // could never be merged. This is the CAPTURE half; the companion ./metro-transformer.js runs in
144
+ // the workers, recording each import as written and serving the LOAD half, and withStasis wires
145
+ // both. Under bundle=load this serializer records nothing: Metro resolved every import itself,
146
+ // so it asserts each edge against the bundle (see #assertEdges).
117
147
  // Capture is one-shot (dev-server rebuilds refused, see #run) and REQUIRES --child-process so the
118
148
  // worker-side toolchain (babel, RN preset) is attested (enforced in the constructor). withStasis
119
149
  // wires the stable customSerializer -- the only surface that receives preModules; serializerHook
120
150
  // is a lower-coverage fallback that never sees them. Prefer withStasis for a second reason too: a
121
- // hand-wired serializer leaves the Metro config -- and therefore its transform cache -- out of
122
- // stasis's hands (see withStasis).
151
+ // hand-wired serializer leaves the Metro config -- and therefore its transform cache and
152
+ // transformerPath -- out of stasis's hands (see withStasis).
123
153
  export class StasisMetro {
124
154
  #seen = new Set()
125
155
  #state
@@ -198,9 +228,12 @@ export class StasisMetro {
198
228
 
199
229
  #run(graph, preModules) {
200
230
  if (!this.#state) return
201
- // bundle=load: pass through (load is served by the worker transformer). Checked BEFORE the
202
- // one-shot guard -- load captures nothing and legitimately re-serializes under a dev server.
203
- if (this.#state.config.loadBundle) return
231
+ // bundle=load: assert, don't record (the worker transformer serves the bundle's bytes). Checked
232
+ // BEFORE the one-shot guard -- load captures nothing and legitimately re-serializes under a dev server.
233
+ if (this.#state.config.loadBundle) {
234
+ this.#assertEdges(graph, preModules)
235
+ return
236
+ }
204
237
  // Watch/dev-server capture is unsupported: dedupe is keyed by PATH not content, so a rebuild
205
238
  // with changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Refuse
206
239
  // the second serialization loudly (the first rebuild is the earliest refusal point Metro allows).
@@ -236,16 +269,18 @@ export class StasisMetro {
236
269
  // AFTER the graph so #seen already holds graph modules -- an auto-include that landed in the
237
270
  // graph (asyncRequire, when the app uses dynamic import()) is deduped, not re-recorded.
238
271
  this.#captureAutoIncludes()
272
+ this.#captureTransformPlugins()
239
273
  // AFTER the graph + auto-includes (same dedupe reason): the native pass only adds the native
240
274
  // build-input surface (podspec/gradle/sources) that no module graph carries.
241
275
  this.#captureNativeModules()
242
276
  }
243
277
 
244
- // Shared epilogue for the classify-driven capture paths: record under the given format/resource.
245
- // `format` null/undefined lets addFile pick (loader format for JS, byte-derived for a resource);
246
- // addFile already asserts UTF-8 for a non-resource (code) source, so no guard is needed here.
247
- #recordCapture(full, source, { format, resource }) {
248
- this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, reason: 'metro' })
278
+ // Shared epilogue for the classify-driven capture paths: record under the given format/resource,
279
+ // attributed to `reason`. `format` null/undefined lets addFile pick (loader format for JS unless
280
+ // `inferFormat` is false, byte-derived for a resource); addFile already asserts UTF-8 for a
281
+ // non-resource (code) source, so no guard is needed here.
282
+ #recordCapture(full, source, { format, resource, inferFormat = true, reason = 'metro' }) {
283
+ this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason })
249
284
  }
250
285
 
251
286
  // Attest the AUTO_INCLUDES list. Per entry, skip: unresolvable, already-seen, or resolving
@@ -270,8 +305,34 @@ export class StasisMetro {
270
305
  // Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
271
306
  // the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
272
307
  // would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
308
+ // JS (asyncRequire.js) is code Metro builds, so it takes no format, as the graph's doesn't.
273
309
  const format = classifyFormat(modPath, { content: source })
274
- this.#recordCapture(modPath, source, { format, resource: format === undefined })
310
+ this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false })
311
+ }
312
+ }
313
+
314
+ // Attest each of Metro's own transform plugins (metroTransformPluginFiles). Its transform worker
315
+ // hashes every one into its cache key, but loads one only as the build's options call for it
316
+ // (import-export-plugin under `experimentalImportSupport`, inline-requires-plugin under
317
+ // `inlineRequires`, constant-folding-plugin outside dev, normalizePseudoGlobals when minifying), so
318
+ // a capture loads them all, as Metro would: under `stasis run` the loader then records each with
319
+ // what it imports, so a bundle builds under any of those options. Each is recorded besides, as the
320
+ // Node module it is, where no loader records it, attributed to `run` as one Metro loads is: the
321
+ // build's toolchain, none of it the app's. One outside the project root is passed over.
322
+ #captureTransformPlugins() {
323
+ for (const file of metroTransformPluginFiles(this.#projectDir)) {
324
+ if (this.#seen.has(file)) continue
325
+ try {
326
+ this.#state.relative(file)
327
+ } catch {
328
+ continue
329
+ }
330
+ this.#seen.add(file)
331
+ try {
332
+ require(file)
333
+ } catch { /* one that can't load can't run under Metro either: its bytes are still recorded */ }
334
+ // Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
335
+ this.#recordCapture(file, realReadFileSync(file), { reason: 'run' })
275
336
  }
276
337
  }
277
338
 
@@ -300,7 +361,7 @@ export class StasisMetro {
300
361
  } catch {
301
362
  continue
302
363
  }
303
- this.#captureNativeTree(root, true)
364
+ this.#captureNativeTree(root)
304
365
  }
305
366
  // RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
306
367
  if (typeof rnPath === 'string' && isAbsolute(rnPath)) {
@@ -336,73 +397,59 @@ export class StasisMetro {
336
397
  if (!nm || nm.name === 'react-native') continue
337
398
  pkgRoots.add(nm.dir)
338
399
  }
339
- return [...pkgRoots].filter((root) => this.#looksNative(root))
400
+ return [...pkgRoots].filter((root) => looksNative(root, diskHost))
340
401
  }
341
402
 
342
- #looksNative(root) {
343
- if (existsSync(join(root, 'ios')) || existsSync(join(root, 'android'))) return true
344
- return this.#hasPodspec(root)
403
+ // Attest the native build inputs of the package at `root` (nativeInputs, shared with `stasis bundle
404
+ // --metro`), but what is already captured as a graph module or auto-include. Real readers: plugin
405
+ // bookkeeping, not re-recorded by --fs.
406
+ #captureNativeTree(root) {
407
+ for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
408
+ this.#seen.add(file)
409
+ this.#recordCapture(file, source, { format, resource: action === 'resource' })
410
+ }
345
411
  }
346
412
 
347
- // True if any *.podspec exists under `dir`, pruning the same subtrees the capture walk skips.
348
- #hasPodspec(dir) {
349
- let entries
350
- try {
351
- entries = realReaddirSync(dir, { withFileTypes: true })
352
- } catch {
353
- return false
354
- }
355
- for (const ent of entries) {
356
- if (ent.isSymbolicLink()) continue
357
- if (ent.isFile()) {
358
- if (isPodspec(ent.name)) return true
359
- } else if (ent.isDirectory() && !isSkippedNativeWalkDir(ent.name)) {
360
- if (this.#hasPodspec(join(dir, ent.name))) return true
361
- }
413
+ // bundle=load: Metro resolved every import itself (the loader and the esbuild/webpack plugins
414
+ // take theirs from the bundle), so before any output each edge must be the one the bundle attests
415
+ // -- the edges a capture records, from the same modules.
416
+ // A per-platform edge (a `stasis bundle --metro --platforms=...` bundle added to) is checked for the
417
+ // platform Metro builds.
418
+ #assertEdges(graph, preModules) {
419
+ const platform = graph.transformOptions?.platform ?? undefined
420
+ for (const module of [...(preModules ?? []), ...graph.dependencies.values()]) {
421
+ const modPath = diskPath(module)
422
+ if (modPath === undefined) continue
423
+ const url = pathToFileURL(modPath).toString()
424
+ for (const [specifier, target] of this.#edges(module)) this.#state.assertImport(url, specifier, target, { platform })
362
425
  }
363
- return false
364
426
  }
365
427
 
366
- // Recursively attest native build-input files under `dir`. Genuine readers (plugin bookkeeping,
367
- // not re-recorded by --fs); symlinks not followed (cycle/escape hazard).
368
- #captureNativeTree(dir, atRoot = false) {
369
- let entries
370
- try {
371
- entries = realReaddirSync(dir, { withFileTypes: true })
372
- } catch {
373
- return // dir missing / not a directory -- nothing to walk
374
- }
375
- for (const ent of entries) {
376
- if (ent.isSymbolicLink()) continue
377
- const full = join(dir, ent.name)
378
- if (ent.isDirectory()) {
379
- // Skip build-output subtrees, Apple binary bundles + their loose per-arch slice dirs, and an
380
- // off-platform toplevel dir.
381
- if (!isSkippedNativeWalkDir(ent.name) && !(atRoot && isExcludedNativeDir(ent.name))) this.#captureNativeTree(full)
382
- continue
428
+ // A module's outgoing edges, keyed as written, to `dep.absolutePath`. Metro names a dependency
429
+ // (`dep.data.name`) by the specifier Babel left; where a plugin rewrote it, the stasis transformer
430
+ // (./metro-transformer.js) carries the ones written in the source. Code and resource targets alike,
431
+ // as `stasis bundle --metro` and the esbuild plugin record them: an asset is resolved as code is.
432
+ // One the source doesn't write (`stasisInserted`: a Babel helper, the JSX runtime, Metro's
433
+ // asyncRequire) is no edge, as `stasis bundle --metro` has none: its file is carried, as every
434
+ // module of the graph is, and under load Metro resolves it unasserted.
435
+ #edges(module) {
436
+ const edges = []
437
+ for (const dep of module.dependencies?.values?.() ?? []) {
438
+ if (dep?.data?.stasisInserted === true) continue
439
+ const target = dep?.absolutePath
440
+ if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
441
+ if (classifyExtension(target, this.#resources) === 'unknown') continue
442
+ for (const specifier of dep.data?.stasisAsWritten ?? [dep.data?.name]) {
443
+ if (typeof specifier === 'string') edges.push([specifier, pathToFileURL(target).toString()])
383
444
  }
384
- if (!ent.isFile()) continue // sockets/FIFOs/etc. -- no attestable bytes
385
- // Prebuilt binary artifacts are build output, non-deterministic -- never captured.
386
- if (isNativeArtifact(ent.name)) continue
387
- if (this.#seen.has(full)) continue // already captured as a graph module or auto-include
388
- const byName = classifyNativeCapture(ent.name)
389
- // Node-runnable JS/TS is captured via Metro's graph, not here (action==='skip').
390
- if (byName.action === 'skip') continue
391
- const source = realReadFileSync(full)
392
- // Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
393
- const { action, format } = refineNativeCapture(byName, ent.name, source, this.#resources)
394
- if (action === 'skip') continue
395
- this.#seen.add(full)
396
- this.#recordCapture(full, source, { format, resource: action === 'resource' })
397
445
  }
446
+ return edges
398
447
  }
399
448
 
400
449
  #captureModules(modules, entries) {
401
450
  for (const module of modules) {
402
- // module.path is the absolute path (both graph modules and preModules carry it).
403
- const modPath = module?.path
404
- // Skip synthetic/virtual modules (require.context's ?ctx=, __prelude__): no on-disk bytes to hash.
405
- if (typeof modPath !== 'string' || !isAbsolute(modPath) || !existsSync(modPath)) continue
451
+ const modPath = diskPath(module)
452
+ if (modPath === undefined) continue
406
453
 
407
454
  const kind = classifyExtension(modPath, this.#resources)
408
455
  if (kind === 'unknown') {
@@ -412,17 +459,7 @@ export class StasisMetro {
412
459
  )
413
460
  }
414
461
  const url = pathToFileURL(modPath).toString()
415
-
416
- // Outgoing edges: as-written specifier is `dep.data.name`, target `dep.absolutePath`.
417
- // Only code targets get an edge (resource imports are captured as files, no edge).
418
- for (const dep of module.dependencies?.values?.() ?? []) {
419
- const target = dep?.absolutePath
420
- const specifier = dep?.data?.name
421
- if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
422
- if (typeof specifier !== 'string') continue
423
- if (classifyExtension(target, this.#resources) !== 'code') continue
424
- this.#state.addImport(url, specifier, pathToFileURL(target).toString())
425
- }
462
+ for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
426
463
 
427
464
  if (this.#seen.has(modPath)) continue
428
465
  this.#seen.add(modPath)
@@ -434,14 +471,17 @@ export class StasisMetro {
434
471
  if (kind === 'code') {
435
472
  assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
436
473
  }
437
- this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', reason: 'metro' })
474
+ // No format for a code file: Metro transforms it from source, so Node's module/commonjs isn't
475
+ // what runs (a resource still takes its byte-derived one).
476
+ this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', inferFormat: false, reason: 'metro' })
438
477
  }
439
478
  }
440
479
  }
441
480
 
442
481
  // Idiomatic Metro-config wrapper: returns a new config with `serializer.customSerializer` wired
443
482
  // so stasis captures the graph + preModules while your existing serializer (or Metro's default)
444
- // still produces the bundle, and with Metro's transform cache dropped so the workers really run.
483
+ // still produces the bundle, with Metro's transform cache dropped so the workers really run, and
484
+ // with Metro's transformer run through stasis's (./metro-transformer.js).
445
485
  // Pure -- returns a new object, doesn't mutate `config`.
446
486
  export function withStasis(config = {}, options = {}) {
447
487
  const stasis = new StasisMetro(options, { ownsConfig: true })
@@ -474,9 +514,18 @@ export function withStasis(config = {}, options = {}) {
474
514
  'transform skips the worker, leaving the toolchain it loads unattested'
475
515
  )
476
516
  }
517
+ // The workers run the project's transformer through stasis's, which records each import as
518
+ // written, before Babel rewrites it, and serves bundle=load: handed over in `transformer`, which
519
+ // Metro passes to each worker's transformer as is. In every active mode, load included, so the
520
+ // modules a worker loads are the same when recorded and when replayed.
521
+ const transformer = stasis.needsTransforms && {
522
+ transformerPath: TRANSFORMER,
523
+ transformer: { ...config.transformer, stasisBaseTransformerPath: config.transformerPath },
524
+ }
477
525
  return {
478
526
  ...config,
479
527
  ...(dropCache && { cacheStores: [] }),
528
+ ...transformer,
480
529
  serializer: {
481
530
  ...config.serializer,
482
531
  customSerializer: stasis.customSerializer(existing),