@exodus/stasis-plugins 1.0.0-beta.8 → 1.0.0-rc.0

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/src/metro.js CHANGED
@@ -1,39 +1,51 @@
1
1
  import assert from 'node:assert/strict'
2
- import { isUtf8 } from 'node:buffer'
2
+ import { Buffer } 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, resolve } from 'node:path'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
8
 
9
- import { resolvePluginState } from './plugins.js'
9
+ import { assertCodeUtf8, classifyOrThrow, resolvePluginState } from './plugins.js'
10
+ import { AUTO_INCLUDES, FILE_STAT, RN_CORE_INCLUDE_FILES, bundleDirHost, looksNative, metroAssetFiles, 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
+ // realReadFileSync throughout: the plugin's own reads, which --fs must not record as the program's.
14
+ import { inScope, realReadFileSync } from '@exodus/stasis-core/state-util'
15
+ import { classifyExtension, classifyFormat, isPathWithin, nodeExtFormat, splitNodeModulesPath } from '@exodus/stasis-core/util'
13
16
 
14
17
  const require = createRequire(import.meta.url)
15
18
 
16
- // Metro's `graph.entryPoints` is a Set now, an Array before ~0.71; normalize to a Set.
17
- function entrySet(graph) {
18
- const ep = graph?.entryPoints
19
- if (ep instanceof Set) return ep
20
- if (Array.isArray(ep)) return new Set(ep)
21
- return new Set()
19
+ const TRANSFORMER = fileURLToPath(new URL('./metro-transformer.js', import.meta.url))
20
+
21
+ // A graph module's absolute path, or undefined for a virtual one (require.context's ?ctx=, __prelude__).
22
+ function diskPath(module) {
23
+ const modPath = module?.path
24
+ return typeof modPath === 'string' && isAbsolute(modPath) && existsSync(modPath) ? modPath : undefined
22
25
  }
23
26
 
24
- // Files a React Native build requires that Metro's module graph never carries; attested (tagged by
25
- // the shared classifier, see #captureAutoIncludes) when they resolve from the project.
26
- const AUTO_INCLUDES = [
27
- 'metro-runtime/src/modules/asyncRequire.js',
28
- '@react-native-community/cli/setup_env.sh',
29
- ]
27
+ // Metro's `graph.entryPoints` is a Set, an Array before ~0.71.
28
+ const entrySet = ({ entryPoints }) => new Set(entryPoints instanceof Set || Array.isArray(entryPoints) ? entryPoints : [])
29
+
30
+ // The files Metro's transform worker hashes into its cache key for its transform plugins: from the project, else
31
+ // through metro and its transform worker (a pnpm install links metro-transform-plugins only beside them).
32
+ function metroTransformPluginFiles(projectDir) {
33
+ for (const chain of [['metro-transform-plugins'], ['metro', 'metro-transform-worker', 'metro-transform-plugins']]) {
34
+ let file
35
+ try {
36
+ for (const name of chain) file = require.resolve(name, { paths: [file === undefined ? projectDir : dirname(file)] })
37
+ } catch {
38
+ continue
39
+ }
40
+ const { getTransformPluginCacheKeyFiles } = require(file)
41
+ return typeof getTransformPluginCacheKeyFiles === 'function' ? getTransformPluginCacheKeyFiles() : []
42
+ }
43
+ return []
44
+ }
30
45
 
31
- // The worker-side toolchain that only Metro's transform workers load, named in both the
32
- // --child-process assert and the transform-cache warning so the two can't describe different sets.
33
46
  const WORKER_TOOLCHAIN = 'babel.config.js, @babel/core, the RN preset + plugins'
34
47
 
35
- // Run `react-native config` (RN's autolinking resolver) for native deps. Null when the RN CLI
36
- // isn't installed; THROWS when present but failing -- silently under-attesting breaks frozen runs.
48
+ // `react-native config`'s output, or null without the RN CLI; a failing CLI throws, as under-attesting breaks frozen runs.
37
49
  function loadReactNativeConfig(projectDir) {
38
50
  let cliPath
39
51
  try {
@@ -41,27 +53,14 @@ function loadReactNativeConfig(projectDir) {
41
53
  } catch {
42
54
  return null
43
55
  }
44
- // Run the child as vanilla Node: strip stasis's env and any inherited loader, else it joins
45
- // this capture nondeterministically or aborts on a config it can't satisfy.
46
- const env = { ...process.env }
47
- for (const key of Object.keys(env)) {
48
- if (key.startsWith('EXODUS_STASIS_')) delete env[key]
49
- }
50
- delete env.NODE_OPTIONS
51
- const res = spawnSync(process.execPath, [cliPath, 'config'], {
52
- cwd: projectDir,
53
- encoding: 'utf8',
54
- maxBuffer: 128 * 1024 * 1024, // a large monorepo's config JSON can be many MB
55
- env,
56
- })
57
- if (res.error) {
58
- throw new Error(`StasisMetro: failed to run 'react-native config': ${res.error.message}`, { cause: res.error })
59
- }
56
+ // As vanilla Node: with stasis's env or an inherited loader, it would join this capture nondeterministically.
57
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('EXODUS_STASIS_') && key !== 'NODE_OPTIONS'))
58
+ const res = spawnSync(process.execPath, [cliPath, 'config'], { cwd: projectDir, encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, env })
59
+ if (res.error) throw new Error(`StasisMetro: failed to run 'react-native config': ${res.error.message}`, { cause: res.error })
60
60
  if (res.status !== 0) {
61
61
  const detail = (res.stderr || '').trim()
62
62
  throw new Error(
63
- `StasisMetro: 'react-native config' exited ${res.status}${res.signal ? ` on signal ${res.signal}` : ''}` +
64
- `${detail ? `: ${detail}` : ''}`
63
+ `StasisMetro: 'react-native config' exited ${res.status}${res.signal ? ` on signal ${res.signal}` : ''}${detail ? `: ${detail}` : ''}`
65
64
  )
66
65
  }
67
66
  try {
@@ -71,28 +70,27 @@ function loadReactNativeConfig(projectDir) {
71
70
  }
72
71
  }
73
72
 
74
- // Native when at least one platform config is non-null; an all-null dep is JS-only (already in the graph).
73
+ // An all-null dep is JS-only, already in the graph.
75
74
  function isNativeDependency(dep) {
76
75
  const platforms = dep?.platforms
77
- if (!platforms || typeof platforms !== 'object') return false
78
- return Object.values(platforms).some((p) => p != null)
76
+ return typeof platforms === 'object' && platforms !== null && Object.values(platforms).some((p) => p != null)
79
77
  }
80
78
 
81
- // Metro has no public default-serializer export; reproduce its default output via internal
82
- // baseJSBundle + bundleToString. Path moved: metro/src/* through ~0.82, metro/private/* from
83
- // 0.81.4 on (the only option from 0.83), so the two-tier require below spans the range.
79
+ // Metro exports no default serializer: its internal one is under metro/src through ~0.82, metro/private from 0.81.4.
84
80
  let metroDefault
85
81
  function metroDefaultSerializer() {
86
82
  if (metroDefault) return metroDefault
87
83
  const M = 'metro'
84
+ const load = (dir) => ['DeltaBundler/Serializers/baseJSBundle', 'lib/bundleToString'].map((lib) => {
85
+ const mod = require(`${M}/${dir}/${lib}`)
86
+ return mod.default ?? mod
87
+ })
88
88
  let baseJSBundle, bundleToString
89
89
  try {
90
- baseJSBundle = require(`${M}/src/DeltaBundler/Serializers/baseJSBundle`)
91
- bundleToString = require(`${M}/src/lib/bundleToString`)
90
+ [baseJSBundle, bundleToString] = load('src')
92
91
  } catch {
93
92
  try {
94
- baseJSBundle = require(`${M}/private/DeltaBundler/Serializers/baseJSBundle`)
95
- bundleToString = require(`${M}/private/lib/bundleToString`)
93
+ [baseJSBundle, bundleToString] = load('private')
96
94
  } catch (cause) {
97
95
  throw new Error(
98
96
  "StasisMetro: couldn't load Metro's default serializer (tried metro/src and " +
@@ -101,48 +99,29 @@ function metroDefaultSerializer() {
101
99
  )
102
100
  }
103
101
  }
104
- // Metro ships CJS; require exposes the fn as the namespace or under `.default`.
105
- baseJSBundle = baseJSBundle.default ?? baseJSBundle
106
- bundleToString = bundleToString.default ?? bundleToString
107
- metroDefault = (entryPoint, preModules, graph, options) =>
108
- bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
102
+ metroDefault = (entryPoint, preModules, graph, options) => bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
109
103
  return metroDefault
110
104
  }
111
105
 
112
- // Stasis capture plugin for Metro (React Native). Hooks Metro's serializer, which runs ONCE in
113
- // 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.
117
- // Capture is one-shot (dev-server rebuilds refused, see #run) and REQUIRES --child-process so the
118
- // worker-side toolchain (babel, RN preset) is attested (enforced in the constructor). withStasis
119
- // wires the stable customSerializer -- the only surface that receives preModules; serializerHook
120
- // 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).
106
+ // Hooks Metro's serializer, which runs once in the main process with the full graph (a worker's State could never
107
+ // be merged); ./metro-transformer.js is the worker half. Under bundle=load it asserts each edge against the bundle.
108
+ // Prefer withStasis: serializerHook never sees preModules, and only withStasis can drop Metro's transform cache.
123
109
  export class StasisMetro {
124
110
  #seen = new Set()
111
+ #realRoot
112
+ #bundleDirs
125
113
  #state
126
114
  #resources
127
-
128
- // Base dir for resolution; snapshotted at construction so a later chdir can't skew capture.
115
+ // Snapshotted so a later chdir can't skew capture.
129
116
  #projectDir = process.cwd()
130
-
131
- // Whether this instance already captured; the dev-server rebuild guard in #run trips on the second.
132
117
  #ran = false
133
-
134
- // Set by withStasis, which returns the Metro config and can therefore drop its transform cache;
135
- // a hand-wired customSerializer/serializerHook can't, so #run warns instead. Internal wiring, in
136
- // the second options bag like StasisEsbuild's -- not a user-facing option.
137
118
  #ownsConfig
138
119
 
139
120
  constructor(options = {}, { ownsConfig = false } = {}) {
140
121
  const { state } = resolvePluginState('StasisMetro', options, process.cwd())
141
- this.#state = state // null when the plugin should be inert (Rule 0 or Rule 7)
122
+ this.#state = state
142
123
  this.#ownsConfig = ownsConfig
143
- // A capture-that-writes REQUIRES --child-process: the worker toolchain (babel, RN preset) is
144
- // observed only in workers. Gate on writeLockfile||writeBundle so it's demanded only when
145
- // forwarding does something (frozen/load verify per-process and need no channel).
124
+ // The worker toolchain is only ever loaded in workers, so a writing capture needs --child-process.
146
125
  if (state && (state.config.writeLockfile || state.config.writeBundle)) {
147
126
  assert.ok(
148
127
  state.config.childProcess,
@@ -151,66 +130,52 @@ export class StasisMetro {
151
130
  'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
152
131
  'or "childProcess": true in stasis.config.json.'
153
132
  )
154
- // Opt this build's children into the loader's SIGTERM shard flush (handler in hooks.js) so
155
- // jest-worker forceExit doesn't silently drop a non-draining worker's shard. Key on the
156
- // PRELOAD's mode where one exists (the shard channel follows it, not a Rule-6 sidecar), and
157
- // use `||=` not `=` so an explicit ambient opt-out ('0'/'false') is honored.
133
+ // Opt the children into the loader's SIGTERM shard flush, else jest-worker's forceExit drops a shard. Keyed on
134
+ // the preload's mode, which the shard channel follows; `||=` honours an explicit opt-out.
158
135
  const channel = State.preload ?? state
159
136
  if (channel.config.writeLockfile || channel.config.writeBundle) {
160
137
  process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1'
161
138
  }
162
139
  }
163
- // Cache the resolved resources Set for the per-file classify hot path.
164
140
  this.#resources = state?.config.resources ?? new Set()
165
141
  }
166
142
 
167
- // True when this build's guarantees depend on Metro actually TRANSFORMING files: a capture records
168
- // what the workers load, frozen checks it, and load REPLAYS it -- all three break if a cached
169
- // transform skips the work. False only when inert or attesting nothing ('none'/'ignore', what
170
- // useLockfile/bundle exclude). Read by withStasis to decide whether to drop the transform cache.
143
+ // Whether the build relies on Metro really transforming files (capture, frozen and load all do), which a cached
144
+ // transform would skip.
171
145
  get needsTransforms() {
172
146
  const config = this.#state?.config
173
- if (!config) return false
174
- return config.useLockfile || config.bundle
147
+ return config !== undefined && (config.useLockfile || config.bundle)
175
148
  }
176
149
 
177
- // Build a Metro `serializer.customSerializer`: captures the graph + preModules (which the
178
- // experimental hook never sees), then delegates to base or reproduces Metro's default output.
179
150
  customSerializer(baseSerializer = undefined) {
180
151
  if (baseSerializer !== undefined && typeof baseSerializer !== 'function') {
181
152
  throw new TypeError('StasisMetro.customSerializer(base?): base must be a function or omitted')
182
153
  }
183
- // Inert (#state === null): hand the base straight back -- the wrapper below would otherwise
184
- // fall back to metroDefaultSerializer(), loading Metro internals a do-nothing plugin mustn't touch.
154
+ // Inert: the base as is, never loading Metro's internals.
185
155
  if (!this.#state) return baseSerializer
186
156
  return (entryPoint, preModules, graph, options) => {
187
157
  this.#run(graph, preModules)
188
- const serialize = baseSerializer ?? metroDefaultSerializer()
189
- return serialize(entryPoint, preModules, graph, options)
158
+ return (baseSerializer ?? metroDefaultSerializer())(entryPoint, preModules, graph, options)
190
159
  }
191
160
  }
192
161
 
193
- // `serializer.experimentalSerializerHook`: observation-only, and LOWER COVERAGE -- Metro never
194
- // passes preModules here, so the prepended polyfills/runtime go unattested. Prefer customSerializer.
195
162
  serializerHook = (graph, _delta) => {
196
163
  this.#run(graph)
197
164
  }
198
165
 
199
166
  #run(graph, preModules) {
200
167
  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
204
- // Watch/dev-server capture is unsupported: dedupe is keyed by PATH not content, so a rebuild
205
- // with changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Refuse
206
- // the second serialization loudly (the first rebuild is the earliest refusal point Metro allows).
168
+ // Before the one-shot guard: load records nothing, and may re-serialize under a dev server.
169
+ if (this.#state.config.loadBundle) {
170
+ this.#assertEdges(graph, preModules)
171
+ return
172
+ }
207
173
  if (this.#ran) {
208
174
  throw new Error(
209
175
  'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
210
176
  )
211
177
  }
212
178
  this.#ran = true
213
- // Keyed on ownership: a withStasis-built instance already dropped the stores (see withStasis).
214
179
  // Nothing here can detect a cache hit, so say it once rather than silently under-attest.
215
180
  if (!this.#ownsConfig && this.needsTransforms) {
216
181
  console.warn(
@@ -221,35 +186,27 @@ export class StasisMetro {
221
186
  )
222
187
  }
223
188
  this.#capture(graph, preModules)
224
-
225
- // The preload writes itself (hooks.js); standalone/sidecar States are written here. Reaching
226
- // serialization means a clean build (a failed build throws first), so no error gate is needed.
189
+ // The preload writes itself; a failed build never reaches serialization.
227
190
  if (this.#state !== State.preload) this.#state.write()
228
191
  }
229
192
 
230
193
  #capture(graph, preModules) {
231
194
  const entries = entrySet(graph)
232
- // preModules first (Metro's prepended polyfills/runtime; synthetic ones like __prelude__ are
233
- // skipped by the existsSync guard below), then the app graph.
234
- if (preModules) this.#captureModules(preModules, entries)
235
- this.#captureModules(graph.dependencies.values(), entries)
236
- // AFTER the graph so #seen already holds graph modules -- an auto-include that landed in the
237
- // graph (asyncRequire, when the app uses dynamic import()) is deduped, not re-recorded.
195
+ const platform = graph.transformOptions?.platform ?? null
196
+ if (preModules) this.#captureModules(preModules, entries, platform)
197
+ this.#captureModules(graph.dependencies.values(), entries, platform)
238
198
  this.#captureAutoIncludes()
239
- // AFTER the graph + auto-includes (same dedupe reason): the native pass only adds the native
240
- // build-input surface (podspec/gradle/sources) that no module graph carries.
199
+ this.#captureTransformPlugins()
200
+ // Last, past what is already captured: the native build inputs no module graph carries.
241
201
  this.#captureNativeModules()
242
202
  }
243
203
 
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' })
204
+ #recordCapture(full, source, { format, resource, inferFormat = true, reason = 'metro' }) {
205
+ this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason })
249
206
  }
250
207
 
251
- // Attest the AUTO_INCLUDES list. Per entry, skip: unresolvable, already-seen, or resolving
252
- // outside the project root (keys are root-relative). Attested entries ride the normal addFile path.
208
+ // Each of AUTO_INCLUDES resolving within the project root, tagged by the shared classifier so it agrees with any
209
+ // other path meeting the file (setup_env.sh is 'shell'), else a resource.
253
210
  #captureAutoIncludes() {
254
211
  for (const specifier of AUTO_INCLUDES) {
255
212
  let modPath
@@ -258,61 +215,44 @@ export class StasisMetro {
258
215
  } catch {
259
216
  continue
260
217
  }
261
- if (this.#seen.has(modPath)) continue
262
- try {
263
- this.#state.relative(modPath)
264
- } catch {
265
- continue
266
- }
218
+ if (this.#seen.has(modPath) || !this.#within(modPath)) continue
267
219
  this.#seen.add(modPath)
268
- // Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
269
220
  const source = realReadFileSync(modPath)
270
- // Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
271
- // the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
272
- // would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
273
221
  const format = classifyFormat(modPath, { content: source })
274
- this.#recordCapture(modPath, source, { format, resource: format === undefined })
222
+ this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false, reason: 'run' })
275
223
  }
276
224
  }
277
225
 
278
- // Attest each native dependency's build-input surface. Discovery is `react-native config`; each
279
- // dep root is walked (isSkippedNativeWalkDir pruned) and its native sources are attested as code,
280
- // other assets as resources, plus each package.json. Node-runnable JS/TS is skipped (already in
281
- // Metro's graph). Skipped when the RN CLI is absent or a root is outside root.
226
+ // Metro's worker hashes every transform plugin into its cache key, but loads each only as the build's options call
227
+ // for it, so a capture loads them all, as the loader then records with their imports, and records each under `run`.
228
+ #captureTransformPlugins() {
229
+ for (const file of metroTransformPluginFiles(this.#projectDir)) {
230
+ if (this.#seen.has(file) || !this.#within(file)) continue
231
+ this.#seen.add(file)
232
+ try {
233
+ require(file)
234
+ } catch { /* one that can't load can't run under Metro either: its bytes are still recorded */ }
235
+ this.#recordCapture(file, realReadFileSync(file), { reason: 'run' })
236
+ }
237
+ }
238
+
239
+ // The native build inputs of the deps autolinking reports, of those the graph reached that ship native code
240
+ // (integrated by hand), and of RN core, which config names only as reactNativePath. One outside the root is skipped.
282
241
  #captureNativeModules() {
283
242
  const config = loadReactNativeConfig(this.#projectDir)
284
243
  if (!config) return
285
244
  const roots = new Set()
286
- // Autolinked native deps: linked into the app regardless of whether their JS is imported.
287
245
  for (const dep of Object.values(config.dependencies ?? {})) {
288
246
  if (isNativeDependency(dep) && typeof dep.root === 'string' && isAbsolute(dep.root)) roots.add(dep.root)
289
247
  }
290
- // Native modules the app imports but autolinking never reports (manually integrated via Podfile).
291
248
  for (const root of this.#reachedNativePackageRoots()) roots.add(root)
292
- // RN CORE isn't a `dependencies` entry (config reports only reactNativePath) and autolinking
293
- // never lists it, but its native source (React/, ReactCommon/, ReactAndroid/, sdks/, ...) is a
294
- // build input like any other dep's -- walk it in full, the same way.
295
- const rnPath = config.reactNativePath
296
- if (typeof rnPath === 'string' && isAbsolute(rnPath)) roots.add(rnPath)
297
- for (const root of roots) {
298
- try {
299
- this.#state.relative(root) // asserts the package lives inside the project root
300
- } catch {
301
- continue
302
- }
303
- this.#captureNativeTree(root, true)
304
- }
305
- // RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
306
- if (typeof rnPath === 'string' && isAbsolute(rnPath)) {
307
- try {
308
- this.#state.relative(rnPath)
309
- for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
310
- } catch { /* react-native resolved outside the project root -- unattestable */ }
311
- }
249
+ const rnPath = typeof config.reactNativePath === 'string' && isAbsolute(config.reactNativePath) ? config.reactNativePath : null
250
+ if (rnPath !== null) roots.add(rnPath)
251
+ for (const root of roots) if (this.#within(root)) this.#captureNativeTree(root)
252
+ // RN core's `.js` build scripts, which the walk leaves to Metro but its graph never has.
253
+ if (rnPath !== null && this.#within(rnPath)) for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
312
254
  }
313
255
 
314
- // Attest a single vetted file (RN_CORE_INCLUDE_FILES) the tree walk skips; absent -> skipped.
315
- // Tagged via the shared classifier like every other path (a `.js` build script -> code).
316
256
  #captureNativeFile(full) {
317
257
  if (this.#seen.has(full)) return
318
258
  let source
@@ -326,160 +266,196 @@ export class StasisMetro {
326
266
  this.#recordCapture(full, source, { format, resource: format === undefined })
327
267
  }
328
268
 
329
- // node_modules roots the graph reached that also ship native code (podspec or ios/android dir):
330
- // the manually-integrated native modules `react-native config` omits. Derived from #seen; RN core
331
- // is skipped here because it's added to the walk explicitly (via reactNativePath).
269
+ // RN core is walked as reactNativePath.
332
270
  #reachedNativePackageRoots() {
333
- const pkgRoots = new Set()
334
- for (const abs of this.#seen) {
335
- const nm = splitNodeModulesPath(abs)
336
- if (!nm || nm.name === 'react-native') continue
337
- pkgRoots.add(nm.dir)
338
- }
339
- return [...pkgRoots].filter((root) => this.#looksNative(root))
271
+ const reached = this.#seen.values().map((abs) => splitNodeModulesPath(abs)).filter((nm) => nm && nm.name !== 'react-native')
272
+ return [...new Set(reached.map((nm) => nm.dir))].filter((root) => looksNative(root, diskHost))
340
273
  }
341
274
 
342
- #looksNative(root) {
343
- if (existsSync(join(root, 'ios')) || existsSync(join(root, 'android'))) return true
344
- return this.#hasPodspec(root)
275
+ // In the format nativeInputs gives (none for a codegen source, as Metro's code has none, so none is inferred).
276
+ #captureNativeTree(root) {
277
+ for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
278
+ this.#seen.add(file)
279
+ this.#recordCapture(file, source, { format, resource: action === 'resource', inferFormat: false })
280
+ }
345
281
  }
346
282
 
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
283
+ // bundle=load: Metro resolved every import itself, so each edge must be the one the bundle attests, for the
284
+ // platform Metro builds.
285
+ #assertEdges(graph, preModules) {
286
+ const platform = graph.transformOptions?.platform ?? undefined
287
+ for (const module of [...(preModules ?? []), ...graph.dependencies.values()]) {
288
+ const modPath = diskPath(module)
289
+ if (modPath === undefined) continue
290
+ const url = pathToFileURL(modPath).toString()
291
+ for (const [specifier, target] of this.#edges(module)) this.#state.assertImport(url, specifier, target, { platform })
292
+ for (const files of this.#consumedResources(module, modPath)) {
293
+ this.#assertOnDisk(files, { what: `which a Babel plugin inlines into '${modPath}'`, servedBy: ['sync', 'async'] })
294
+ }
295
+ this.#assertAssetFiles(module, modPath, platform ?? null)
354
296
  }
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
297
+ }
298
+
299
+ // A module's edges, keyed as the source wrote them (`stasisAsWritten`, where Babel rewrote one), to code and resource
300
+ // targets alike. One the source doesn't write (`stasisInserted`: a Babel helper, Metro's asyncRequire) is no edge.
301
+ #edges(module) {
302
+ const edges = []
303
+ for (const dep of module.dependencies?.values?.() ?? []) {
304
+ if (dep?.data?.stasisInserted === true) continue
305
+ const target = dep?.absolutePath
306
+ if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
307
+ if (classifyExtension(target, this.#resources) === 'unknown') continue
308
+ for (const specifier of dep.data?.stasisAsWritten ?? [dep.data?.name]) {
309
+ if (typeof specifier === 'string') edges.push([specifier, pathToFileURL(target).toString()])
361
310
  }
362
311
  }
363
- return false
312
+ return edges
313
+ }
314
+
315
+ #captureModules(modules, entries, platform) {
316
+ for (const module of modules) {
317
+ const modPath = diskPath(module)
318
+ if (modPath === undefined) continue
319
+ const kind = classifyOrThrow('StasisMetro', modPath, this.#resources)
320
+ const url = pathToFileURL(modPath).toString()
321
+ for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
322
+ if (!this.#captureRead(modPath, kind, entries.has(modPath))) continue
323
+ for (const { file } of this.#consumedResources(module, modPath)) this.#captureRead(file, 'resource')
324
+ this.#captureAssetFiles(module, modPath, platform)
325
+ }
326
+ }
327
+
328
+ #within(path) {
329
+ return isPathWithin(this.#state.root, path)
364
330
  }
365
331
 
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
332
+ #realWithin(path) {
333
+ let real
370
334
  try {
371
- entries = realReaddirSync(dir, { withFileTypes: true })
335
+ real = diskHost.realpath(path)
336
+ this.#realRoot ??= diskHost.realpath(this.#state.root)
372
337
  } catch {
373
- return // dir missing / not a directory -- nothing to walk
338
+ return null
374
339
  }
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
383
- }
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' })
340
+ return this.#within(real) && isPathWithin(this.#realRoot, real) ? real : null
341
+ }
342
+
343
+ // The resources a Babel plugin inlined in transforming `module` (`stasisConsumed`, `require('./messages.po')`): no
344
+ // module of Metro's, so no edge, but build inputs. One naming code (babel-plugin-lodash's split imports) is passed
345
+ // over. -> each as { file: its real path within the root, read: the path read }
346
+ * #consumedResources(module, modPath) {
347
+ for (const specifier of module.output?.[0]?.data?.stasisConsumed ?? []) {
348
+ if (typeof specifier !== 'string' || !(isAbsolute(specifier) || /^\.\.?\//u.test(specifier))) continue
349
+ const read = resolve(dirname(modPath), specifier)
350
+ const file = diskHost.stat(read)?.isFile() ? this.#realWithin(read) : null
351
+ if (file === null) continue
352
+ if (classifyOrThrow('StasisMetro', file, this.#resources, `'${file}', which a Babel plugin inlines into '${modPath}'`) === 'code') continue
353
+ yield { file, read }
397
354
  }
398
355
  }
399
356
 
400
- #captureModules(modules, entries) {
401
- 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
406
-
407
- const kind = classifyExtension(modPath, this.#resources)
408
- if (kind === 'unknown') {
409
- throw new Error(
410
- `StasisMetro: unsupported extension for '${modPath}' -- ` +
411
- `add its extension or filename to the plugin's resources option or stop importing it`
412
- )
413
- }
414
- const url = pathToFileURL(modPath).toString()
357
+ // Capture `path` once, as the `kind` classifyExtension gives it: code with only the format its name gives
358
+ // (nodeExtFormat), as Metro transforms it from source. From disk: addFile re-reads it to assert the bytes.
359
+ // -> whether it was new
360
+ #captureRead(path, kind, isEntry = false) {
361
+ if (this.#seen.has(path)) return false
362
+ this.#seen.add(path)
363
+ const source = realReadFileSync(path)
364
+ if (kind === 'code') assertCodeUtf8('StasisMetro', path, source)
365
+ const format = kind === 'code' ? nodeExtFormat(path) : undefined
366
+ const resource = kind === 'resource'
367
+ this.#state.addFile(pathToFileURL(path).toString(), { source, format, isEntry, resource, inferFormat: false, reason: 'metro' })
368
+ return true
369
+ }
415
370
 
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
- }
371
+ // The files Metro reads to build the asset module `module` (its `scales` and `hash`) and copies out with it: it
372
+ // and its other scales (metroAssetFiles, Metro's own rule for `platform`). No import names one, so no edge.
373
+ #assetFiles(module, modPath, platform) {
374
+ return module.output?.[0]?.type === 'js/module/asset' ? metroAssetFiles(modPath, platform, this.#assetDirHost()) : []
375
+ }
426
376
 
427
- if (this.#seen.has(modPath)) continue
428
- this.#seen.add(modPath)
377
+ // Where Metro lists an asset's files: on disk, but under load with --fs=async, which serves the listing, the
378
+ // bundle's (a file added on disk since is none of the asset's; one gone from disk holds no drift).
379
+ #assetDirHost() {
380
+ const state = this.#state
381
+ if (!state.config.loadBundle || state.config.fs !== 'async') return diskHost
382
+ return {
383
+ readdir: (dir) => state.getFsDirFamily(pathToFileURL(dir).toString())?.map((name) => ({ name })) ?? diskHost.readdir(dir),
384
+ stat: (path) => diskHost.stat(path) ?? (state.getFsStatFamily(pathToFileURL(path).toString()) === 'file' ? FILE_STAT : null),
385
+ }
386
+ }
429
387
 
430
- // Read from disk, not module.getSource() -- addFile re-reads disk and asserts byte-equality.
431
- // Real reader (state-util.js): a --fs-patched readFileSync would re-record the graph into the preload.
432
- const source = realReadFileSync(modPath)
433
- // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
434
- if (kind === 'code') {
435
- assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
436
- }
437
- this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', reason: 'metro' })
388
+ #bundleDirHost() {
389
+ return (this.#bundleDirs ??= bundleDirHost(this.#state))
390
+ }
391
+
392
+ // By the name Metro reads each by, with the bytes it reads there, so a load holds that name to them. One linked
393
+ // from outside the project is passed over, as is one of a kind unknown (the module itself was refused).
394
+ #captureAssetFiles(module, modPath, platform) {
395
+ for (const read of this.#assetFiles(module, modPath, platform)) {
396
+ const kind = classifyExtension(read, this.#resources)
397
+ if (kind !== 'unknown' && this.#realWithin(read) !== null) this.#captureRead(read, kind)
438
398
  }
439
399
  }
400
+
401
+ // Metro reads an asset's files from disk, past the stasis transformer: each must be the bundle's under that name (a
402
+ // scale added since the capture, or one a symlink redirects, is drift), and each the bundle carries must still be
403
+ // there (one gone builds another asset) -- unless --fs=async serves them.
404
+ #assertAssetFiles(module, modPath, platform) {
405
+ const read = this.#assetFiles(module, modPath, platform)
406
+ if (read.length === 0) return
407
+ const what = `a file of the asset '${modPath}' Metro builds`
408
+ for (const path of read) {
409
+ if (diskHost.stat(path)?.isFile()) this.#assertOnDisk({ file: path, read: path }, { what, servedBy: ['async'] })
410
+ }
411
+ if (!inScope(this.#state, pathToFileURL(modPath).toString())) return
412
+ for (const file of metroAssetFiles(modPath, platform, this.#bundleDirHost())) {
413
+ if (read.includes(file)) continue
414
+ throw new Error(`StasisMetro: '${file}', ${what} as the bundle carries it, is gone -- Metro would build the asset without it under load; restore it`)
415
+ }
416
+ }
417
+
418
+ // `read`, a file Metro reads from disk past the stasis transformer, held to the bundle's bytes for `file` where in
419
+ // scope (getFile throws on one it doesn't carry) -- unless --fs, in a mode of `servedBy`, serves it by the path read.
420
+ #assertOnDisk({ file, read }, { what, servedBy }) {
421
+ const state = this.#state
422
+ const url = pathToFileURL(file).toString()
423
+ if (!inScope(state, url)) return
424
+ if (servedBy.includes(state.config.fs) && state.getFsFileFamily(pathToFileURL(read).toString()) !== undefined) return
425
+ const { source } = state.getFile(url)
426
+ if (realReadFileSync(file).equals(Buffer.isBuffer(source) ? source : Buffer.from(source))) return
427
+ throw new Error(
428
+ `StasisMetro: '${file}', ${what}, differs on disk from the bundle -- ` +
429
+ 'Metro\'s build reads it from disk under load; restore it, or run with --fs to serve it from the bundle'
430
+ )
431
+ }
440
432
  }
441
433
 
442
- // Idiomatic Metro-config wrapper: returns a new config with `serializer.customSerializer` wired
443
- // 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.
445
- // Pure -- returns a new object, doesn't mutate `config`.
434
+ // A new Metro config whose customSerializer captures the graph and preModules while the existing serializer (else
435
+ // Metro's default) still produces the bundle, and whose transformer runs through ./metro-transformer.js.
446
436
  export function withStasis(config = {}, options = {}) {
447
437
  const stasis = new StasisMetro(options, { ownsConfig: true })
448
438
  const existing = config.serializer?.customSerializer ?? undefined
449
- // Metro's transform cache is a CORRECTNESS hazard here, not a speed knob: it decides which modules
450
- // Metro itself LOADS. On a cache hit Metro returns the stored result without calling a worker, so
451
- // the worker-side toolchain is never loaded in any process and never reaches the root -- exactly
452
- // what the constructor's --child-process assert exists to guarantee. The default store is a
453
- // FileStore in the OS tmpdir shared with every other Metro run, so ONE earlier `metro build`/dev
454
- // server silently decides what gets attested: a capture over a warm cache drops the toolchain
455
- // (exit 0, no warning) and a later frozen run rejects the lockfile it wrote, while a frozen run
456
- // over a warm cache passes vacuously (it verifies transforms nobody performed).
457
- //
458
- // Dropped in EVERY active mode, load included, so the module set can't diverge between record and
459
- // replay. `new Transformer` calls getTransformCacheKey() only when the cache is enabled
460
- // (metro/src/DeltaBundler/Transformer.js: `this._cache.isDisabled ? '' : ...`), and that call tree
461
- // resolves the transformer plus its plugins' cache-key files. Disabled during capture, those edges
462
- // are never recorded; left enabled under load they resolve for real and getImport fails closed on
463
- // a bundle that cannot know them ("Cannot find module 'metro-transform-worker' imported from
464
- // .../getTransformCacheKey.js" -- Metro reports it as "Failed to construct transformer").
465
- //
466
- // Slower, but the loaded set stops depending on what ran before. These builds are one-shot anyway.
467
- const dropCache = stasis.needsTransforms
468
- // `undefined` is Metro's own default store, which the user never chose; an array or a
469
- // `(MetroCache) => stores` factory is theirs, so replacing it is worth saying out loud.
439
+ // Metro's transform cache decides which modules Metro loads: a hit skips the worker, so its toolchain goes
440
+ // unattested and a frozen run passes vacuously, and the default store is shared with every other Metro run. Dropped
441
+ // under load too: an enabled cache resolves its cache-key files, which a bundle can't know, failing getImport.
442
+ const active = stasis.needsTransforms
443
+ // `undefined` is Metro's own default store; an array or a factory is the user's, so replacing it is announced.
470
444
  const stores = config.cacheStores
471
- if (dropCache && (typeof stores === 'function' || stores?.length > 0)) {
445
+ if (active && (typeof stores === 'function' || stores?.length > 0)) {
472
446
  console.warn(
473
447
  '[stasis] StasisMetro: ignoring the `cacheStores` in your Metro config for this build -- a cached ' +
474
448
  'transform skips the worker, leaving the toolchain it loads unattested'
475
449
  )
476
450
  }
451
+ // Metro hands `transformer` to each worker's transformer as is.
477
452
  return {
478
453
  ...config,
479
- ...(dropCache && { cacheStores: [] }),
480
- serializer: {
481
- ...config.serializer,
482
- customSerializer: stasis.customSerializer(existing),
483
- },
454
+ ...(active && {
455
+ cacheStores: [],
456
+ transformerPath: TRANSFORMER,
457
+ transformer: { ...config.transformer, stasisBaseTransformerPath: config.transformerPath },
458
+ }),
459
+ serializer: { ...config.serializer, customSerializer: stasis.customSerializer(existing) },
484
460
  }
485
461
  }