@exodus/stasis-plugins 1.0.0-beta.1 → 1.0.0-beta.10

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,24 +1,19 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { isUtf8 } from 'node:buffer'
3
+ import { spawnSync } from 'node:child_process'
3
4
  import { existsSync } from 'node:fs'
4
5
  import { createRequire } from 'node:module'
5
- import { isAbsolute } from 'node:path'
6
+ import { isAbsolute, join } from 'node:path'
6
7
  import { pathToFileURL } from 'node:url'
7
8
 
8
9
  import { resolvePluginState } from './plugins.js'
9
10
  import { State } from '@exodus/stasis-core/state'
10
- import { realReadFileSync } from '@exodus/stasis-core/state-util'
11
- import { classifyExtension } from '@exodus/stasis-core/util'
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'
12
13
 
13
14
  const require = createRequire(import.meta.url)
14
15
 
15
- // `graph.entryPoints` is a `Set<string>` of absolute entry paths in current Metro (it was
16
- // an `Array<string>` before ~0.71; it has never been a Map). Normalize to a Set so
17
- // `isEntry` is a single `.has`. The one-shot `metro build` passes the graph but no separate
18
- // entry arg, and `customSerializer`'s separate `entryPoint` string names only the PRIMARY
19
- // entry, so `graph.entryPoints` is the authoritative full set (matters for multi-entry
20
- // builds). An absent/odd shape yields an empty set -- nothing marked an entry rather than
21
- // throwing, matching how the other plugins treat an entry they can't positively identify.
16
+ // Metro's `graph.entryPoints` is a Set now, an Array before ~0.71; normalize to a Set.
22
17
  function entrySet(graph) {
23
18
  const ep = graph?.entryPoints
24
19
  if (ep instanceof Set) return ep
@@ -26,17 +21,66 @@ function entrySet(graph) {
26
21
  return new Set()
27
22
  }
28
23
 
29
- // Metro has no public "default serializer" export, but its default bundle CODE is exactly
30
- // `bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code` (Metro's own
31
- // Server.js else-branch when no customSerializer is set). bundleToString returns
32
- // `{ code, metadata }` -- we return the `code` string; a plain string is a valid
33
- // customSerializer return and Metro regenerates the source map itself, the same way its
34
- // else-branch does. These internal module paths are the ecosystem-standard entry (Expo,
35
- // metro-serializer-*): `metro/src/*` resolves through ~0.82, `metro/private/*` exists from
36
- // 0.81.4 on (and is the ONLY option from 0.83), so the two-tier require below spans the
37
- // range. Loaded lazily and ONLY when withStasis()/customSerializer() must produce output
38
- // with no user-supplied base, so a normal capture build never touches Metro internals --
39
- // and the `metro` spec is built indirectly so it isn't treated as a hard dependency of stasis.
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
+ ]
30
+
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
+ const WORKER_TOOLCHAIN = 'babel.config.js, @babel/core, the RN preset + plugins'
34
+
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.
37
+ function loadReactNativeConfig(projectDir) {
38
+ let cliPath
39
+ try {
40
+ cliPath = require.resolve('react-native/cli.js', { paths: [projectDir] })
41
+ } catch {
42
+ return null
43
+ }
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
+ }
60
+ if (res.status !== 0) {
61
+ const detail = (res.stderr || '').trim()
62
+ throw new Error(
63
+ `StasisMetro: 'react-native config' exited ${res.status}${res.signal ? ` on signal ${res.signal}` : ''}` +
64
+ `${detail ? `: ${detail}` : ''}`
65
+ )
66
+ }
67
+ try {
68
+ return JSON.parse(res.stdout)
69
+ } catch (cause) {
70
+ throw new Error("StasisMetro: couldn't parse 'react-native config' output as JSON", { cause })
71
+ }
72
+ }
73
+
74
+ // Native when at least one platform config is non-null; an all-null dep is JS-only (already in the graph).
75
+ function isNativeDependency(dep) {
76
+ const platforms = dep?.platforms
77
+ if (!platforms || typeof platforms !== 'object') return false
78
+ return Object.values(platforms).some((p) => p != null)
79
+ }
80
+
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.
40
84
  let metroDefault
41
85
  function metroDefaultSerializer() {
42
86
  if (metroDefault) return metroDefault
@@ -57,126 +101,88 @@ function metroDefaultSerializer() {
57
101
  )
58
102
  }
59
103
  }
60
- // Interop: Metro ships CJS; `require` of its Flow-compiled modules exposes the function
61
- // either as the namespace itself or under `.default`.
104
+ // Metro ships CJS; require exposes the fn as the namespace or under `.default`.
62
105
  baseJSBundle = baseJSBundle.default ?? baseJSBundle
63
106
  bundleToString = bundleToString.default ?? bundleToString
64
- // Return the `.code` string (bundleToString yields { code, metadata }); a string is a
65
- // valid customSerializer return and Metro regenerates the source map itself.
66
107
  metroDefault = (entryPoint, preModules, graph, options) =>
67
108
  bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
68
109
  return metroDefault
69
110
  }
70
111
 
71
- // Stasis plugin for the Metro bundler (React Native).
72
- //
73
- // WHY THIS HOOKS THE SERIALIZER, NOT A TRANSFORMER:
74
- // Metro transforms each file in a pool of WORKER processes. A stasis State is
75
- // process-local (its observed sources/imports/formats live in in-memory Maps
76
- // flushed by State.write on the main process), so a transformer-side capture
77
- // would scatter observations across workers that never reach the writer.
78
- // Metro's serializer runs once, in the MAIN process, AFTER the whole graph is
79
- // built, with the complete module graph in hand -- the one place a single State
80
- // can see every module and resolution edge (mirrors webpack's afterResolve and
81
- // esbuild's onResolve/onLoad, which likewise run in the bundler's main process).
82
- //
83
- // THIS PLUGIN IS THE CAPTURE HALF -- LOAD LIVES IN A SEPARATE TRANSFORMER:
84
- // The other plugins serve bundle bytes back into the build because their bundler
85
- // reads + transforms files in the same process the bundle lives in (webpack's
86
- // inputFileSystem wrapper, esbuild's onLoad). Metro reads + transforms in workers,
87
- // before serialization, so the serializer can't inject bytes into the transform.
88
- // Load is instead handled per-worker by the companion `./metro-transformer.js`
89
- // (wired as Metro's `transformer.transformerPath`): unlike capture, load only ever
90
- // READS the immutable bundle and every worker reads the same bytes, so there is no
91
- // cross-worker state to merge. This serializer therefore REJECTS bundle=load.
92
- // Capture modes (lock/bundle add|replace|frozen, and lock=none/ignore) all work.
93
- //
94
- // USAGE (metro.config.js) -- prefer withStasis:
95
- // const { withStasis } = require('@exodus/stasis/metro')
96
- // module.exports = withStasis(require('./metro.config.base'), { /* scope, lock, ... */ })
97
- //
98
- // Then CAPTURE with child-process forwarding ON -- Metro transforms in worker processes, so
99
- // `--child-process` is REQUIRED on a capture run to attest the toolchain those workers load
100
- // (babel.config.js, @babel/core, the RN preset + plugins). The plugin throws at config time if
101
- // you forget it on a writing run: `stasis run --lock=add --child-process -- <metro build cmd>`.
102
- // A later `--lock=frozen` verify needs NO flag (verification is per-process, independent of the
103
- // shard channel). withStasis wires the STABLE `serializer.customSerializer`, which is the only
104
- // Metro surface that receives `preModules` (the polyfills/runtime Metro prepends to every
105
- // bundle -- real shipped files). It wraps an existing customSerializer if you have one,
106
- // otherwise reproduces Metro's default output. For manual wiring use
107
- // `new StasisMetro(opts).customSerializer(base?)`. `serializerHook` (the experimental
108
- // hook) is a lower-coverage fallback -- see its note.
109
- //
110
- // KNOWN LIMITATIONS (Metro-specific coverage gaps -- documented, not silently ignored):
111
- // - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer
112
- // per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe
113
- // would otherwise keep attesting a file's first-build bytes after an edit changed them.
114
- // Metro gives the serializer no watch signal on the first build, so the first rebuild is the
115
- // earliest refusal point. Load mode (the companion worker transformer) is unaffected and
116
- // runs fine under a dev server -- it only reads immutable attested bytes.
117
- // - Child-process capture is best-effort within that one shot. A worker KILLED by signal
118
- // before its exit hook (jest-worker forceExit / pool overflow) loses its shard SILENTLY at
119
- // capture time -- a later frozen run still catches the gap (fail-closed), but nothing warns
120
- // during capture. And it relies on the loader's exit-time write, which standalone (no stasis
121
- // loader) Metro never reaches -- it mints no shard dir and produces no toolchain-complete
122
- // lockfile. Use a one-shot `metro build` under `stasis run --child-process` to capture, then
123
- // verify with `--lock=frozen`.
124
- // - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
125
- // by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
126
- // scales/files arrays), NOT as separate graph modules, so only the base file is attested
127
- // here. Assets pulled in by the native build via `react-native.config.js` (e.g. fonts
128
- // through `react-native-asset`) aren't in Metro's JS graph at all and aren't covered.
129
- // - Platform-specific lockfiles: edges are recorded under the as-written specifier
130
- // ('./Foo') resolved to the platform variant Metro picked ('Foo.ios.js'), all under the
131
- // '*' conditions key. A lockfile captured for one platform won't match a frozen run for
132
- // another -- capture/verify per platform. It fails CLOSED (rejects, never silently
133
- // accepts a mismatched resolution).
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).
134
123
  export class StasisMetro {
135
124
  #seen = new Set()
136
125
  #state
137
126
  #resources
138
127
 
139
- // Whether this instance already served one capture (#run). The serializer fires once per
140
- // one-shot `metro build`; a dev server re-invokes it per rebuild, which capture refuses --
141
- // see the guard in #run.
128
+ // Base dir for resolution; snapshotted at construction so a later chdir can't skew capture.
129
+ #projectDir = process.cwd()
130
+
131
+ // Whether this instance already captured; the dev-server rebuild guard in #run trips on the second.
142
132
  #ran = false
143
133
 
144
- constructor(options = {}) {
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
+ #ownsConfig
138
+
139
+ constructor(options = {}, { ownsConfig = false } = {}) {
145
140
  const { state } = resolvePluginState('StasisMetro', options, process.cwd())
146
- this.#state = state // null when the plugin should be inert (Rule 7)
147
- // A CAPTURE-THAT-WRITES requires child-process forwarding: Metro transforms every file in a
148
- // pool of worker processes (see the class note), and the toolchain those workers load --
149
- // babel.config.js, @babel/core, the React Native preset + its plugins -- is observed only
150
- // there. Without --child-process those workers can't report back, so that toolchain goes
151
- // unattested and a later frozen/load run rejects it. Fail loudly at config time rather than
152
- // silently emit an incomplete lockfile. Gate on the SAME predicate as the shard channel itself
153
- // (writeLockfile || writeBundle, i.e. lock/bundle add|replace) so the flag is demanded exactly
154
- // when forwarding does something: a frozen/ignore/none run verifies per-process and needs no
155
- // channel (the CLI frozen tests pass without the flag), and load mode reads the bundle via the
156
- // companion transformer -- none of those should be forced to pass --child-process.
141
+ this.#state = state // null when the plugin should be inert (Rule 0 or Rule 7)
142
+ 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).
157
146
  if (state && (state.config.writeLockfile || state.config.writeBundle)) {
158
147
  assert.ok(
159
148
  state.config.childProcess,
160
149
  'StasisMetro: child-process capture must be enabled -- Metro transforms in worker processes, ' +
161
- 'and without it the toolchain they load (babel.config.js, @babel/core, the RN preset + plugins) ' +
150
+ `and without it the toolchain they load (${WORKER_TOOLCHAIN}) ` +
162
151
  'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
163
152
  'or "childProcess": true in stasis.config.json.'
164
153
  )
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.
158
+ const channel = State.preload ?? state
159
+ if (channel.config.writeLockfile || channel.config.writeBundle) {
160
+ process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1'
161
+ }
165
162
  }
166
- // resources is a Config field now (validated + coordinated against any preload in
167
- // resolvePluginState); cache the resolved Set for the per-file classify hot path.
163
+ // Cache the resolved resources Set for the per-file classify hot path.
168
164
  this.#resources = state?.config.resources ?? new Set()
169
165
  }
170
166
 
171
- // Build a Metro `serializer.customSerializer`: captures the graph AND `preModules`
172
- // (Metro's prepended polyfills/runtime -- real shipped files that the experimental hook
173
- // never sees), writes state, then produces output. With a `baseSerializer` we delegate to
174
- // it; with none we reproduce Metro's default output. This is the stable, full-coverage
175
- // surface (withStasis wires it for you).
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.
171
+ get needsTransforms() {
172
+ const config = this.#state?.config
173
+ if (!config) return false
174
+ return config.useLockfile || config.bundle
175
+ }
176
+
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.
176
179
  customSerializer(baseSerializer = undefined) {
177
180
  if (baseSerializer !== undefined && typeof baseSerializer !== 'function') {
178
181
  throw new TypeError('StasisMetro.customSerializer(base?): base must be a function or omitted')
179
182
  }
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.
185
+ if (!this.#state) return baseSerializer
180
186
  return (entryPoint, preModules, graph, options) => {
181
187
  this.#run(graph, preModules)
182
188
  const serialize = baseSerializer ?? metroDefaultSerializer()
@@ -184,72 +190,218 @@ export class StasisMetro {
184
190
  }
185
191
  }
186
192
 
187
- // Wire into `serializer.experimentalSerializerHook`. Observation-only: Metro ignores the
188
- // return value, so stasis never produces output -- convenient when you can't touch
189
- // `customSerializer`. LOWER COVERAGE: the hook's signature is `(graph, delta)`; Metro never
190
- // passes `preModules` to it, so the prepended polyfills/runtime go UNATTESTED. Prefer
191
- // withStasis/customSerializer. Bound so it can be passed by reference.
193
+ // `serializer.experimentalSerializerHook`: observation-only, and LOWER COVERAGE -- Metro never
194
+ // passes preModules here, so the prepended polyfills/runtime go unattested. Prefer customSerializer.
192
195
  serializerHook = (graph, _delta) => {
193
196
  this.#run(graph)
194
197
  }
195
198
 
196
199
  #run(graph, preModules) {
197
200
  if (!this.#state) return
198
- if (this.#state.config.loadBundle) {
199
- throw new Error(
200
- 'StasisMetro: bundle=load is served by the companion worker transformer ' +
201
- "(transformer.transformerPath = '@exodus/stasis/metro-transformer'), not this serializer " +
202
- 'plugin -- Metro transforms in workers before serialization. Remove this plugin from your ' +
203
- 'serializer config in load mode, or use bundle=add/replace/frozen here to capture.'
204
- )
205
- }
206
- // Watch/dev-server capture is EXPLICITLY UNSUPPORTED -- refuse the second serialization
207
- // loudly before it captures anything. Capture's dedupe is keyed by PATH, not content
208
- // (#seen below, plus State.write's compare-and-skip caches), so on a rebuild where a
209
- // file's bytes changed the plugin would skip re-capture: the emitted bundle would use the
210
- // new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones. The
211
- // serializer fires exactly once per one-shot `metro build`; only a dev server
212
- // (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on
213
- // the FIRST build, so the first rebuild is the earliest point capture can refuse. (Load
214
- // mode has no such hazard and legitimately runs under a dev server -- it lives in the
215
- // companion worker transformer, rejected above, which only READS immutable attested bytes.)
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).
216
207
  if (this.#ran) {
217
208
  throw new Error(
218
209
  'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
219
210
  )
220
211
  }
221
212
  this.#ran = true
213
+ // Keyed on ownership: a withStasis-built instance already dropped the stores (see withStasis).
214
+ // Nothing here can detect a cache hit, so say it once rather than silently under-attest.
215
+ if (!this.#ownsConfig && this.needsTransforms) {
216
+ console.warn(
217
+ "[stasis] StasisMetro: wired without withStasis(), so Metro's transform cache is outside " +
218
+ `stasis's control -- a cached transform skips the worker and the toolchain it loads ` +
219
+ `(${WORKER_TOOLCHAIN}) goes unattested. Wrap your config in withStasis(), or set ` +
220
+ '`cacheStores: []` yourself for every stasis build.'
221
+ )
222
+ }
222
223
  this.#capture(graph, preModules)
223
224
 
224
- // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
225
- // Standalone and sidecar States have no such hook, so the plugin writes them here. The
226
- // serializer only runs once Metro has successfully built + transformed the whole graph
227
- // (a failed build throws before serialization), so this is the clean-build equivalent of
228
- // webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate -- and it runs at
229
- // most once per instance (the rebuild guard above), so this write is the one-shot build's
230
- // single flush.
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.
231
227
  if (this.#state !== State.preload) this.#state.write()
232
228
  }
233
229
 
234
230
  #capture(graph, preModules) {
235
231
  const entries = entrySet(graph)
236
- // preModules first (Metro's prepended polyfills/runtime + InitializeCore -- real on-disk
237
- // files that ship in every bundle; only the customSerializer path receives them, and the
238
- // synthetic ones like __prelude__ are skipped by the existsSync guard below). Then the
239
- // app module 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.
240
234
  if (preModules) this.#captureModules(preModules, entries)
241
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.
238
+ 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.
241
+ this.#captureNativeModules()
242
+ }
243
+
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' })
249
+ }
250
+
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.
253
+ #captureAutoIncludes() {
254
+ for (const specifier of AUTO_INCLUDES) {
255
+ let modPath
256
+ try {
257
+ modPath = require.resolve(specifier, { paths: [this.#projectDir] })
258
+ } catch {
259
+ continue
260
+ }
261
+ if (this.#seen.has(modPath)) continue
262
+ try {
263
+ this.#state.relative(modPath)
264
+ } catch {
265
+ continue
266
+ }
267
+ this.#seen.add(modPath)
268
+ // Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
269
+ 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
+ const format = classifyFormat(modPath, { content: source })
274
+ this.#recordCapture(modPath, source, { format, resource: format === undefined })
275
+ }
276
+ }
277
+
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.
282
+ #captureNativeModules() {
283
+ const config = loadReactNativeConfig(this.#projectDir)
284
+ if (!config) return
285
+ const roots = new Set()
286
+ // Autolinked native deps: linked into the app regardless of whether their JS is imported.
287
+ for (const dep of Object.values(config.dependencies ?? {})) {
288
+ if (isNativeDependency(dep) && typeof dep.root === 'string' && isAbsolute(dep.root)) roots.add(dep.root)
289
+ }
290
+ // Native modules the app imports but autolinking never reports (manually integrated via Podfile).
291
+ 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
+ }
312
+ }
313
+
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
+ #captureNativeFile(full) {
317
+ if (this.#seen.has(full)) return
318
+ let source
319
+ try {
320
+ source = realReadFileSync(full)
321
+ } catch {
322
+ return
323
+ }
324
+ this.#seen.add(full)
325
+ const format = classifyFormat(full, { content: source })
326
+ this.#recordCapture(full, source, { format, resource: format === undefined })
327
+ }
328
+
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).
332
+ #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))
340
+ }
341
+
342
+ #looksNative(root) {
343
+ if (existsSync(join(root, 'ios')) || existsSync(join(root, 'android'))) return true
344
+ return this.#hasPodspec(root)
345
+ }
346
+
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
+ }
362
+ }
363
+ return false
364
+ }
365
+
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
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' })
397
+ }
242
398
  }
243
399
 
244
400
  #captureModules(modules, entries) {
245
401
  for (const module of modules) {
246
- // Metro keys `graph.dependencies` by the module's absolute path; `module.path` is that
247
- // same path and is what preModules carry too, so we read it off the module either way.
402
+ // module.path is the absolute path (both graph modules and preModules carry it).
248
403
  const modPath = module?.path
249
- // Skip synthetic / virtual modules (Metro injects some with non-disk paths, e.g.
250
- // require.context's `?ctx=` modules and __prelude__): a path that isn't an existing
251
- // absolute file has no bytes to hash. Mirrors webpack's `isAbsolute && existsSync`
252
- // guard and esbuild's `namespace === 'file'` gate.
404
+ // Skip synthetic/virtual modules (require.context's ?ctx=, __prelude__): no on-disk bytes to hash.
253
405
  if (typeof modPath !== 'string' || !isAbsolute(modPath) || !existsSync(modPath)) continue
254
406
 
255
407
  const kind = classifyExtension(modPath, this.#resources)
@@ -261,12 +413,8 @@ export class StasisMetro {
261
413
  }
262
414
  const url = pathToFileURL(modPath).toString()
263
415
 
264
- // Record this module's outgoing resolution edges. Metro keys a module's `dependencies`
265
- // by an opaque hash; the as-written specifier is `dep.data.name` (e.g. './hello' or
266
- // 'react-native') and the resolved target is `dep.absolutePath`. We only attest edges
267
- // whose TARGET is code: resource imports (an asset pulled in for its bytes) are captured
268
- // as files below but carry no import edge, matching webpack/esbuild (`kind === 'code'`).
269
- // Edges to virtual/unresolved targets are skipped like the modules above.
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).
270
418
  for (const dep of module.dependencies?.values?.() ?? []) {
271
419
  const target = dep?.absolutePath
272
420
  const specifier = dep?.data?.name
@@ -279,36 +427,56 @@ export class StasisMetro {
279
427
  if (this.#seen.has(modPath)) continue
280
428
  this.#seen.add(modPath)
281
429
 
282
- // Read the bytes from disk rather than `module.getSource()` -- addFile re-reads disk
283
- // and asserts byte-equality against what we pass, and disk is the thing stasis attests.
284
- // Use the REAL reader (state-util.js), like webpack/esbuild: this capture read is the
285
- // plugin's own bookkeeping, NOT a program fs read. `import { readFileSync } from
286
- // 'node:fs'` (the named binding above, before this fix) is rebound to the patched
287
- // reader by `stasis run --fs`'s syncBuiltinESMExports(), so reading through it would
288
- // re-record the whole Metro module graph into the preload/main bundle -- it belongs to
289
- // THIS plugin's sidecar instead.
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.
290
432
  const source = realReadFileSync(modPath)
291
- // Code-classified files must be UTF-8. A code extension carrying non-UTF-8 bytes is
292
- // malformed input; refuse it rather than silently encoding it as a resource. Resource-
293
- // classified files pass through as-is -- State derives 'resource' vs 'resource:base64'.
433
+ // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
294
434
  if (kind === 'code') {
295
435
  assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
296
436
  }
297
- this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', reason: 'StasisMetro' })
437
+ this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', reason: 'metro' })
298
438
  }
299
439
  }
300
440
  }
301
441
 
302
- // Idiomatic Metro-config wrapper (cf. withNativeWind / withSentryConfig): returns a new
303
- // config with `serializer.customSerializer` wired so stasis captures the graph + preModules
304
- // and your existing customSerializer (or Metro's default) still produces the bundle. This is
305
- // the recommended surface -- it uses the stable customSerializer (so preModules are covered)
306
- // rather than the experimental hook. Pure (returns a new object; doesn't mutate `config`).
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`.
307
446
  export function withStasis(config = {}, options = {}) {
308
- const stasis = new StasisMetro(options)
447
+ const stasis = new StasisMetro(options, { ownsConfig: true })
309
448
  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.
470
+ const stores = config.cacheStores
471
+ if (dropCache && (typeof stores === 'function' || stores?.length > 0)) {
472
+ console.warn(
473
+ '[stasis] StasisMetro: ignoring the `cacheStores` in your Metro config for this build -- a cached ' +
474
+ 'transform skips the worker, leaving the toolchain it loads unattested'
475
+ )
476
+ }
310
477
  return {
311
478
  ...config,
479
+ ...(dropCache && { cacheStores: [] }),
312
480
  serializer: {
313
481
  ...config.serializer,
314
482
  customSerializer: stasis.customSerializer(existing),