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

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.12",
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.12"
37
40
  }
38
41
  }
@@ -0,0 +1,86 @@
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 specifier a dependency node holds as a string literal: an import/export's source, a call's
34
+ // first argument. A plugin rewriting it replaces the literal, never the node holding it.
35
+ function specifierOf(node) {
36
+ const literal = node.type === 'CallExpression' ? node.arguments[0] : node.source
37
+ return literal?.type === 'StringLiteral' ? literal.value : undefined
38
+ }
39
+
40
+ function asWrittenPlugin({ types: t }) {
41
+ return {
42
+ name: 'stasis-as-written',
43
+ pre(file) {
44
+ const { slots } = transformContext.getStore()
45
+ t.traverseFast(file.ast, (node) => {
46
+ if (node.type === 'CallExpression' ? !isDependencyCall(node.callee) : !DECLARATIONS.has(node.type)) return
47
+ const written = specifierOf(node)
48
+ if (written !== undefined) slots.push({ node, written })
49
+ })
50
+ },
51
+ visitor: {
52
+ // Babel exits a node only if no plugin removed it on the way in, so a declaration erased for
53
+ // importing types alone (`import type`, `import { type A }`, a TS import used only as a type)
54
+ // never exits: flow-strip-types removes it as it enters it, the TS plugin before the
55
+ // traversal gets there. One lowered to a require exits first: wherever it is listed,
56
+ // @babel/plugin-transform-modules-commonjs takes the declarations away as the program exits.
57
+ 'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': {
58
+ exit(path) {
59
+ transformContext.getStore().kept.add(path.node)
60
+ },
61
+ },
62
+ },
63
+ }
64
+ }
65
+
66
+ export function transform(args) {
67
+ const context = transformContext.getStore()
68
+ assert.ok(context, 'StasisMetro: the stasis Babel transformer runs only under the stasis metro-transformer')
69
+ const slots = []
70
+ const kept = new Set()
71
+ Object.assign(context, { slots, kept })
72
+ // Once Babel is done: each specifier as it left it, from the node that held it as written.
73
+ const collect = (result) => {
74
+ for (const { node, written } of slots) {
75
+ if (DECLARATIONS.has(node.type) && !kept.has(node)) continue
76
+ const left = specifierOf(node)
77
+ if (left === undefined) continue
78
+ if (!context.asWritten.has(left)) context.asWritten.set(left, new Set())
79
+ context.asWritten.get(left).add(written)
80
+ }
81
+ return result
82
+ }
83
+ // Sync or async, as the project's transformer is (the RN one is sync).
84
+ const result = require(context.babelTransformerPath).transform({ ...args, plugins: [...(args.plugins ?? []), asWrittenPlugin] })
85
+ return typeof result?.then === 'function' ? result.then(collect) : collect(result)
86
+ }
@@ -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,31 @@ 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 named by a specifier Babel rewrote, with the ones written in the source. One
92
+ // Metro dependency can stand for several, written apart and rewritten alike (`#local/abc` and
93
+ // `./src/abc`); a dependency written as Metro names it is left as is.
94
+ function withAsWritten(result, asWritten) {
95
+ if (asWritten.size === 0 || !Array.isArray(result?.dependencies)) return result
96
+ const dependencies = result.dependencies.map((dep) => {
97
+ const written = asWritten.get(dep.name)
98
+ if (written === undefined || (written.size === 1 && written.has(dep.name))) return dep
99
+ return { ...dep, stasisAsWritten: [...written] }
100
+ })
101
+ return { ...result, dependencies }
102
+ }
103
+
104
+ export async function transform(config, projectRoot, filename, data, options) {
105
+ const base = baseOf(config, projectRoot)
106
+ config = baseConfig(config)
64
107
  const state = getLoadState()
65
108
  if (state) {
66
109
  // `filename` is project-relative in Metro's contract; resolve against projectRoot.
@@ -72,14 +115,19 @@ export function transform(config, projectRoot, filename, data, options) {
72
115
  data = Buffer.isBuffer(source) ? source : Buffer.from(source)
73
116
  }
74
117
  }
75
- return getBase().transform(config, projectRoot, filename, data, options)
118
+ if (typeof config.babelTransformerPath !== 'string') return base.transformer.transform(config, projectRoot, filename, data, options)
119
+ const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map() }
120
+ const result = await transformContext.run(context, () =>
121
+ base.transformer.transform({ ...config, babelTransformerPath: BABEL_TRANSFORMER }, projectRoot, filename, data, options))
122
+ return withAsWritten(result, context.asWritten)
76
123
  }
77
124
 
78
- export function getCacheKey(config) {
125
+ export function getCacheKey(config, opts) {
79
126
  // Fold in the base transformer's cache key, then add a stasis-load discriminator so load results
80
127
  // (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) : ''
128
+ // Derive the marker from the resolved load State, which transform() goes by.
129
+ const { transformer } = baseOf(config, opts?.projectRoot)
130
+ const baseKey = typeof transformer.getCacheKey === 'function' ? transformer.getCacheKey(baseConfig(config), opts) : ''
83
131
  const state = getLoadState()
84
132
  const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'
85
133
  return `${baseKey}$stasis-metro-transformer:${marker}`
package/src/metro.js CHANGED
@@ -4,15 +4,27 @@ import { spawnSync } from 'node:child_process'
4
4
  import { existsSync } from 'node:fs'
5
5
  import { createRequire } from 'node:module'
6
6
  import { isAbsolute, join } from 'node:path'
7
- import { pathToFileURL } from 'node:url'
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
@@ -111,15 +123,16 @@ function metroDefaultSerializer() {
111
123
 
112
124
  // Stasis capture plugin for Metro (React Native). Hooks Metro's serializer, which runs ONCE in
113
125
  // 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.
126
+ // could never be merged. This is the CAPTURE half; the companion ./metro-transformer.js runs in
127
+ // the workers, recording each import as written and serving the LOAD half, and withStasis wires
128
+ // both. Under bundle=load this serializer records nothing: Metro resolved every import itself,
129
+ // so it asserts each edge against the bundle (see #assertEdges).
117
130
  // Capture is one-shot (dev-server rebuilds refused, see #run) and REQUIRES --child-process so the
118
131
  // worker-side toolchain (babel, RN preset) is attested (enforced in the constructor). withStasis
119
132
  // wires the stable customSerializer -- the only surface that receives preModules; serializerHook
120
133
  // 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).
134
+ // hand-wired serializer leaves the Metro config -- and therefore its transform cache and
135
+ // transformerPath -- out of stasis's hands (see withStasis).
123
136
  export class StasisMetro {
124
137
  #seen = new Set()
125
138
  #state
@@ -198,9 +211,12 @@ export class StasisMetro {
198
211
 
199
212
  #run(graph, preModules) {
200
213
  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
214
+ // bundle=load: assert, don't record (the worker transformer serves the bundle's bytes). Checked
215
+ // BEFORE the one-shot guard -- load captures nothing and legitimately re-serializes under a dev server.
216
+ if (this.#state.config.loadBundle) {
217
+ this.#assertEdges(graph, preModules)
218
+ return
219
+ }
204
220
  // Watch/dev-server capture is unsupported: dedupe is keyed by PATH not content, so a rebuild
205
221
  // with changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Refuse
206
222
  // the second serialization loudly (the first rebuild is the earliest refusal point Metro allows).
@@ -242,10 +258,11 @@ export class StasisMetro {
242
258
  }
243
259
 
244
260
  // 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' })
261
+ // `format` null/undefined lets addFile pick (loader format for JS unless `inferFormat` is false,
262
+ // byte-derived for a resource); addFile already asserts UTF-8 for a non-resource (code) source, so
263
+ // no guard is needed here.
264
+ #recordCapture(full, source, { format, resource, inferFormat = true }) {
265
+ this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason: 'metro' })
249
266
  }
250
267
 
251
268
  // Attest the AUTO_INCLUDES list. Per entry, skip: unresolvable, already-seen, or resolving
@@ -270,8 +287,9 @@ export class StasisMetro {
270
287
  // Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
271
288
  // the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
272
289
  // would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
290
+ // JS (asyncRequire.js) is code Metro builds, so it takes no format, as the graph's doesn't.
273
291
  const format = classifyFormat(modPath, { content: source })
274
- this.#recordCapture(modPath, source, { format, resource: format === undefined })
292
+ this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false })
275
293
  }
276
294
  }
277
295
 
@@ -300,7 +318,7 @@ export class StasisMetro {
300
318
  } catch {
301
319
  continue
302
320
  }
303
- this.#captureNativeTree(root, true)
321
+ this.#captureNativeTree(root)
304
322
  }
305
323
  // RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
306
324
  if (typeof rnPath === 'string' && isAbsolute(rnPath)) {
@@ -336,73 +354,55 @@ export class StasisMetro {
336
354
  if (!nm || nm.name === 'react-native') continue
337
355
  pkgRoots.add(nm.dir)
338
356
  }
339
- return [...pkgRoots].filter((root) => this.#looksNative(root))
357
+ return [...pkgRoots].filter((root) => looksNative(root, diskHost))
340
358
  }
341
359
 
342
- #looksNative(root) {
343
- if (existsSync(join(root, 'ios')) || existsSync(join(root, 'android'))) return true
344
- return this.#hasPodspec(root)
360
+ // Attest the native build inputs of the package at `root` (nativeInputs, shared with `stasis bundle
361
+ // --metro`), but what is already captured as a graph module or auto-include. Real readers: plugin
362
+ // bookkeeping, not re-recorded by --fs.
363
+ #captureNativeTree(root) {
364
+ for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
365
+ this.#seen.add(file)
366
+ this.#recordCapture(file, source, { format, resource: action === 'resource' })
367
+ }
345
368
  }
346
369
 
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
- }
370
+ // bundle=load: Metro resolved every import itself (the loader and the esbuild/webpack plugins
371
+ // take theirs from the bundle), so before any output each edge must be the one the bundle attests
372
+ // -- the edges a capture records, from the same modules.
373
+ // A per-platform edge (a `stasis bundle --metro --platforms=...` bundle added to) is checked for the
374
+ // platform Metro builds.
375
+ #assertEdges(graph, preModules) {
376
+ const platform = graph.transformOptions?.platform ?? undefined
377
+ for (const module of [...(preModules ?? []), ...graph.dependencies.values()]) {
378
+ const modPath = diskPath(module)
379
+ if (modPath === undefined) continue
380
+ const url = pathToFileURL(modPath).toString()
381
+ for (const [specifier, target] of this.#edges(module)) this.#state.assertImport(url, specifier, target, { platform })
362
382
  }
363
- return false
364
383
  }
365
384
 
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
385
+ // A module's outgoing edges, keyed as written, to `dep.absolutePath`. Metro names a dependency
386
+ // (`dep.data.name`) by the specifier Babel left; where a plugin rewrote it, the stasis transformer
387
+ // (./metro-transformer.js) carries the ones written in the source. Code and resource targets alike,
388
+ // as `stasis bundle --metro` and the esbuild plugin record them: an asset is resolved as code is.
389
+ #edges(module) {
390
+ const edges = []
391
+ for (const dep of module.dependencies?.values?.() ?? []) {
392
+ const target = dep?.absolutePath
393
+ if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
394
+ if (classifyExtension(target, this.#resources) === 'unknown') continue
395
+ for (const specifier of dep.data?.stasisAsWritten ?? [dep.data?.name]) {
396
+ if (typeof specifier === 'string') edges.push([specifier, pathToFileURL(target).toString()])
383
397
  }
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
398
  }
399
+ return edges
398
400
  }
399
401
 
400
402
  #captureModules(modules, entries) {
401
403
  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
404
+ const modPath = diskPath(module)
405
+ if (modPath === undefined) continue
406
406
 
407
407
  const kind = classifyExtension(modPath, this.#resources)
408
408
  if (kind === 'unknown') {
@@ -412,17 +412,7 @@ export class StasisMetro {
412
412
  )
413
413
  }
414
414
  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
- }
415
+ for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
426
416
 
427
417
  if (this.#seen.has(modPath)) continue
428
418
  this.#seen.add(modPath)
@@ -434,14 +424,17 @@ export class StasisMetro {
434
424
  if (kind === 'code') {
435
425
  assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
436
426
  }
437
- this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', reason: 'metro' })
427
+ // No format for a code file: Metro transforms it from source, so Node's module/commonjs isn't
428
+ // what runs (a resource still takes its byte-derived one).
429
+ this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', inferFormat: false, reason: 'metro' })
438
430
  }
439
431
  }
440
432
  }
441
433
 
442
434
  // Idiomatic Metro-config wrapper: returns a new config with `serializer.customSerializer` wired
443
435
  // 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.
436
+ // still produces the bundle, with Metro's transform cache dropped so the workers really run, and
437
+ // with Metro's transformer run through stasis's (./metro-transformer.js).
445
438
  // Pure -- returns a new object, doesn't mutate `config`.
446
439
  export function withStasis(config = {}, options = {}) {
447
440
  const stasis = new StasisMetro(options, { ownsConfig: true })
@@ -474,9 +467,18 @@ export function withStasis(config = {}, options = {}) {
474
467
  'transform skips the worker, leaving the toolchain it loads unattested'
475
468
  )
476
469
  }
470
+ // The workers run the project's transformer through stasis's, which records each import as
471
+ // written, before Babel rewrites it, and serves bundle=load: handed over in `transformer`, which
472
+ // Metro passes to each worker's transformer as is. In every active mode, load included, so the
473
+ // modules a worker loads are the same when recorded and when replayed.
474
+ const transformer = stasis.needsTransforms && {
475
+ transformerPath: TRANSFORMER,
476
+ transformer: { ...config.transformer, stasisBaseTransformerPath: config.transformerPath },
477
+ }
477
478
  return {
478
479
  ...config,
479
480
  ...(dropCache && { cacheStores: [] }),
481
+ ...transformer,
480
482
  serializer: {
481
483
  ...config.serializer,
482
484
  customSerializer: stasis.customSerializer(existing),