@exodus/stasis-plugins 1.0.0-beta.2 → 1.0.0-beta.5

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,37 +21,66 @@ function entrySet(graph) {
26
21
  return new Set()
27
22
  }
28
23
 
29
- // Well-known files a React Native / Metro build REQUIRES that the serializer's module
30
- // graph never reliably carries -- attested automatically at capture when they resolve
31
- // from the project, and skipped when they don't (not every Metro project ships them):
32
- // - metro-runtime/src/modules/asyncRequire.js: Metro's default
33
- // `transformer.asyncRequireModulePath`. The transform injects it to implement
34
- // dynamic import(), so it ships in bundles without necessarily appearing as a
35
- // module of the graph handed to the serializer.
36
- // - @react-native-community/cli/setup_env.sh: sourced by the React Native CLI's
37
- // native build phases (Xcode / Gradle) around the JS bundle step -- build-shaping
38
- // toolchain that is never part of the JS module graph at all.
39
- // A `resource: true` entry bypasses the `resources` allowlist: this is a stasis-vetted
40
- // list of SPECIFIC files, not a user-widened extension class. Code entries ride the
41
- // normal loader-format inference. Extend the list here, not via an option -- an option
42
- // would make the attested set configuration-dependent, which is exactly what
43
- // auto-include exists to avoid.
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.
44
26
  const AUTO_INCLUDES = [
45
- { specifier: 'metro-runtime/src/modules/asyncRequire.js', resource: false },
46
- { specifier: '@react-native-community/cli/setup_env.sh', resource: true },
27
+ 'metro-runtime/src/modules/asyncRequire.js',
28
+ '@react-native-community/cli/setup_env.sh',
47
29
  ]
48
30
 
49
- // Metro has no public "default serializer" export, but its default bundle CODE is exactly
50
- // `bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code` (Metro's own
51
- // Server.js else-branch when no customSerializer is set). bundleToString returns
52
- // `{ code, metadata }` -- we return the `code` string; a plain string is a valid
53
- // customSerializer return and Metro regenerates the source map itself, the same way its
54
- // else-branch does. These internal module paths are the ecosystem-standard entry (Expo,
55
- // metro-serializer-*): `metro/src/*` resolves through ~0.82, `metro/private/*` exists from
56
- // 0.81.4 on (and is the ONLY option from 0.83), so the two-tier require below spans the
57
- // range. Loaded lazily and ONLY when withStasis()/customSerializer() must produce output
58
- // with no user-supplied base, so a normal capture build never touches Metro internals --
59
- // and the `metro` spec is built indirectly so it isn't treated as a hard dependency of stasis.
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.
60
84
  let metroDefault
61
85
  function metroDefaultSerializer() {
62
86
  if (metroDefault) return metroDefault
@@ -77,179 +101,88 @@ function metroDefaultSerializer() {
77
101
  )
78
102
  }
79
103
  }
80
- // Interop: Metro ships CJS; `require` of its Flow-compiled modules exposes the function
81
- // either as the namespace itself or under `.default`.
104
+ // Metro ships CJS; require exposes the fn as the namespace or under `.default`.
82
105
  baseJSBundle = baseJSBundle.default ?? baseJSBundle
83
106
  bundleToString = bundleToString.default ?? bundleToString
84
- // Return the `.code` string (bundleToString yields { code, metadata }); a string is a
85
- // valid customSerializer return and Metro regenerates the source map itself.
86
107
  metroDefault = (entryPoint, preModules, graph, options) =>
87
108
  bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
88
109
  return metroDefault
89
110
  }
90
111
 
91
- // Stasis plugin for the Metro bundler (React Native).
92
- //
93
- // WHY THIS HOOKS THE SERIALIZER, NOT A TRANSFORMER:
94
- // Metro transforms each file in a pool of WORKER processes. A stasis State is
95
- // process-local (its observed sources/imports/formats live in in-memory Maps
96
- // flushed by State.write on the main process), so a transformer-side capture
97
- // would scatter observations across workers that never reach the writer.
98
- // Metro's serializer runs once, in the MAIN process, AFTER the whole graph is
99
- // built, with the complete module graph in hand -- the one place a single State
100
- // can see every module and resolution edge (mirrors webpack's afterResolve and
101
- // esbuild's onResolve/onLoad, which likewise run in the bundler's main process).
102
- //
103
- // THIS PLUGIN IS THE CAPTURE HALF -- LOAD LIVES IN A SEPARATE TRANSFORMER:
104
- // The other plugins serve bundle bytes back into the build because their bundler
105
- // reads + transforms files in the same process the bundle lives in (webpack's
106
- // inputFileSystem wrapper, esbuild's onLoad). Metro reads + transforms in workers,
107
- // before serialization, so the serializer can't inject bytes into the transform.
108
- // Load is instead handled per-worker by the companion `./metro-transformer.js`
109
- // (wired as Metro's top-level `transformerPath`): unlike capture, load only ever
110
- // READS the immutable bundle and every worker reads the same bytes, so there is no
111
- // cross-worker state to merge. Under bundle=load this serializer is therefore a
112
- // transparent PASS-THROUGH (captures nothing, writes nothing, delegates to the
113
- // base serializer), mirroring the transformer, which passes through in every
114
- // non-load mode. Wire BOTH halves permanently; the mode picks the active one.
115
- // That matters because metro.config.js is itself attested at capture, so a load
116
- // run must execute it unedited. Capture modes (lock/bundle add|replace|frozen,
117
- // and lock=none/ignore) all capture here.
118
- //
119
- // USAGE (metro.config.js) -- prefer withStasis; wire the load half permanently alongside it:
120
- // const { withStasis } = require('@exodus/stasis/metro')
121
- // module.exports = withStasis({
122
- // ...require('./metro.config.base'),
123
- // transformerPath: require.resolve('@exodus/stasis/metro-transformer'), // the load half
124
- // }, { /* scope, lock, ... */ })
125
- //
126
- // Then CAPTURE with child-process forwarding ON -- Metro transforms in worker processes, so
127
- // `--child-process` is REQUIRED on a capture run to attest the toolchain those workers load
128
- // (babel.config.js, @babel/core, the RN preset + plugins). The plugin throws at config time if
129
- // you forget it on a writing run: `stasis run --lock=add --child-process -- <metro build cmd>`.
130
- // A later `--lock=frozen` verify needs NO flag (verification is per-process, independent of the
131
- // shard channel). withStasis wires the STABLE `serializer.customSerializer`, which is the only
132
- // Metro surface that receives `preModules` (the polyfills/runtime Metro prepends to every
133
- // bundle -- real shipped files). It wraps an existing customSerializer if you have one,
134
- // otherwise reproduces Metro's default output. For manual wiring use
135
- // `new StasisMetro(opts).customSerializer(base?)`. `serializerHook` (the experimental
136
- // hook) is a lower-coverage fallback -- see its note.
137
- //
138
- // AUTO-INCLUDED FILES: capture also attests the fixed AUTO_INCLUDES list (see it above
139
- // the class) -- files a React Native build requires that the serializer's graph never
140
- // reliably carries (Metro's asyncRequire module, the RN CLI's setup_env.sh). Each is
141
- // attested when it resolves from the project and skipped when it doesn't, through the
142
- // same addFile path as graph modules, so frozen verification covers them fail-closed.
143
- // NOTE: a lockfile captured before this list existed doesn't attest these files, so a
144
- // frozen run against it fails once they're present -- recapture (lock=replace) once.
145
- //
146
- // KNOWN LIMITATIONS (Metro-specific coverage gaps -- documented, not silently ignored):
147
- // - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer
148
- // per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe
149
- // would otherwise keep attesting a file's first-build bytes after an edit changed them.
150
- // Metro gives the serializer no watch signal on the first build, so the first rebuild is the
151
- // earliest refusal point. Load mode is unaffected and runs fine under a dev server --
152
- // the serializer passes through and the worker transformer only reads immutable
153
- // attested bytes.
154
- // - Child-process capture is best-effort within that one shot. A writing plugin opts the
155
- // build's workers into the loader's SIGTERM shard flush (EXODUS_STASIS_SHARD_SIGNAL_FLUSH,
156
- // set in the constructor), so a worker force-killed by jest-worker's forceExit -- SIGTERM
157
- // 500ms after END, routine for a transform worker whose event loop doesn't drain --
158
- // contributes its shard best-effort: the flush must complete inside forceExit's follow-up
159
- // 500ms SIGTERM->SIGKILL window. A worker killed any OTHER way still loses its shard
160
- // SILENTLY at capture time -- SIGKILL (jest-worker's memory-limit killChild, a
161
- // killSignal:'SIGKILL' override), a non-SIGTERM signal (group SIGINT/SIGHUP, another
162
- // killSignal override; the flush handler is SIGTERM-only), or a nested orchestration's
163
- // Metro MAIN process (itself a capturing child, it snapshots its config before this
164
- // constructor sets the flag, so only its descendants are covered). A later frozen run
165
- // catches every such gap (fail-closed), but nothing warns during capture. And it relies
166
- // on the loader's exit/signal-time writes, which standalone (no stasis loader) Metro
167
- // never reaches -- it mints no shard dir and produces no toolchain-complete lockfile.
168
- // Use a one-shot `metro build` under `stasis run --child-process` to capture, then
169
- // verify with `--lock=frozen`.
170
- // - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
171
- // by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
172
- // scales/files arrays), NOT as separate graph modules, so only the base file is attested
173
- // here. Assets pulled in by the native build via `react-native.config.js` (e.g. fonts
174
- // through `react-native-asset`) aren't in Metro's JS graph at all and aren't covered.
175
- // - Platform-specific lockfiles: edges are recorded under the as-written specifier
176
- // ('./Foo') resolved to the platform variant Metro picked ('Foo.ios.js'), all under the
177
- // '*' conditions key. A lockfile captured for one platform won't match a frozen run for
178
- // another -- capture/verify per platform. It fails CLOSED (rejects, never silently
179
- // 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).
180
123
  export class StasisMetro {
181
124
  #seen = new Set()
182
125
  #state
183
126
  #resources
184
127
 
185
- // Base dir AUTO_INCLUDES resolution starts from: the directory Metro runs in -- the
186
- // same cwd resolvePluginState anchors the State at, and an in-or-under-root path by
187
- // State's own root detection (which walks UP from it), so Node's node_modules walk-up
188
- // from here also covers hoisted workspace layouts. Snapshotted at construction so a
189
- // later chdir can't skew what a capture attests.
128
+ // Base dir for resolution; snapshotted at construction so a later chdir can't skew capture.
190
129
  #projectDir = process.cwd()
191
130
 
192
- // Whether this instance already served one capture (#run). The serializer fires once per
193
- // one-shot `metro build`; a dev server re-invokes it per rebuild, which capture refuses --
194
- // see the guard in #run.
131
+ // Whether this instance already captured; the dev-server rebuild guard in #run trips on the second.
195
132
  #ran = false
196
133
 
197
- 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 } = {}) {
198
140
  const { state } = resolvePluginState('StasisMetro', options, process.cwd())
199
- this.#state = state // null when the plugin should be inert (Rule 7)
200
- // A CAPTURE-THAT-WRITES requires child-process forwarding: Metro transforms every file in a
201
- // pool of worker processes (see the class note), and the toolchain those workers load --
202
- // babel.config.js, @babel/core, the React Native preset + its plugins -- is observed only
203
- // there. Without --child-process those workers can't report back, so that toolchain goes
204
- // unattested and a later frozen/load run rejects it. Fail loudly at config time rather than
205
- // silently emit an incomplete lockfile. Gate on the SAME predicate as the shard channel itself
206
- // (writeLockfile || writeBundle, i.e. lock/bundle add|replace) so the flag is demanded exactly
207
- // when forwarding does something: a frozen/ignore/none run verifies per-process and needs no
208
- // channel (the CLI frozen tests pass without the flag), and load mode reads the bundle via the
209
- // 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).
210
146
  if (state && (state.config.writeLockfile || state.config.writeBundle)) {
211
147
  assert.ok(
212
148
  state.config.childProcess,
213
149
  'StasisMetro: child-process capture must be enabled -- Metro transforms in worker processes, ' +
214
- '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}) ` +
215
151
  'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
216
152
  'or "childProcess": true in stasis.config.json.'
217
153
  )
218
- // Opt this build's children into the loader's SIGTERM shard flush: jest-worker's
219
- // forceExit would otherwise silently drop a non-draining worker's shard (mechanics in
220
- // the KNOWN LIMITATIONS bullet above; the handler lives in hooks.js). The flag rides
221
- // process.env, so EVERY capturing child forked after config time inherits it --
222
- // Metro's transform workers are the intended audience, but a codegen fork the build
223
- // spawns rides along; the loader's own gates (child + shard forwarding) bound what it
224
- // can do there to a flush-then-redeliver on SIGTERM. The plugin can't act inside the
225
- // workers (only the loader runs there), so an env opt-in is the seam; scoped HERE,
226
- // not globally, because changing a child's default-kill disposition is otherwise the
227
- // user's domain. Two deliberate softenings:
228
- // - keyed on the PRELOAD where one exists: the shard channel is minted by, and the
229
- // children's capture mode follows, the preload's config -- a Rule-6 sidecar's own
230
- // bundle mode says nothing about either, and setting the flag under a frozen
231
- // preload would claim an opt-in that can never engage;
232
- // - `||=`, not `=`: an explicit ambient opt-out ('0'/'false', envBool's disable
233
- // values) is the user's call and is honored; only unset/empty is claimed.
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.
234
158
  const channel = State.preload ?? state
235
159
  if (channel.config.writeLockfile || channel.config.writeBundle) {
236
160
  process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1'
237
161
  }
238
162
  }
239
- // resources is a Config field now (validated + coordinated against any preload in
240
- // 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.
241
164
  this.#resources = state?.config.resources ?? new Set()
242
165
  }
243
166
 
244
- // Build a Metro `serializer.customSerializer`: captures the graph AND `preModules`
245
- // (Metro's prepended polyfills/runtime -- real shipped files that the experimental hook
246
- // never sees), writes state, then produces output. With a `baseSerializer` we delegate to
247
- // it; with none we reproduce Metro's default output. This is the stable, full-coverage
248
- // 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.
249
179
  customSerializer(baseSerializer = undefined) {
250
180
  if (baseSerializer !== undefined && typeof baseSerializer !== 'function') {
251
181
  throw new TypeError('StasisMetro.customSerializer(base?): base must be a function or omitted')
252
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
253
186
  return (entryPoint, preModules, graph, options) => {
254
187
  this.#run(graph, preModules)
255
188
  const serialize = baseSerializer ?? metroDefaultSerializer()
@@ -257,105 +190,218 @@ export class StasisMetro {
257
190
  }
258
191
  }
259
192
 
260
- // Wire into `serializer.experimentalSerializerHook`. Observation-only: Metro ignores the
261
- // return value, so stasis never produces output -- convenient when you can't touch
262
- // `customSerializer`. LOWER COVERAGE: the hook's signature is `(graph, delta)`; Metro never
263
- // passes `preModules` to it, so the prepended polyfills/runtime go UNATTESTED. Prefer
264
- // 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.
265
195
  serializerHook = (graph, _delta) => {
266
196
  this.#run(graph)
267
197
  }
268
198
 
269
199
  #run(graph, preModules) {
270
200
  if (!this.#state) return
271
- // bundle=load: pass through -- load is served by the companion worker transformer, and
272
- // metro.config.js is itself attested at capture, so the same wiring must run unedited
273
- // in load mode (see the class note). Checked BEFORE the one-shot guard: load captures
274
- // nothing and legitimately re-serializes per rebuild under a dev server.
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.
275
203
  if (this.#state.config.loadBundle) return
276
- // Watch/dev-server capture is EXPLICITLY UNSUPPORTED -- refuse the second serialization
277
- // loudly before it captures anything. Capture's dedupe is keyed by PATH, not content
278
- // (#seen below, plus State.write's compare-and-skip caches), so on a rebuild where a
279
- // file's bytes changed the plugin would skip re-capture: the emitted bundle would use the
280
- // new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones. The
281
- // serializer fires exactly once per one-shot `metro build`; only a dev server
282
- // (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on
283
- // the FIRST build, so the first rebuild is the earliest point capture can refuse. (Load
284
- // mode has no such hazard and legitimately runs under a dev server -- it passed through
285
- // above, and the companion worker transformer only READS immutable attested bytes.)
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).
286
207
  if (this.#ran) {
287
208
  throw new Error(
288
209
  'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
289
210
  )
290
211
  }
291
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
+ }
292
223
  this.#capture(graph, preModules)
293
224
 
294
- // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
295
- // Standalone and sidecar States have no such hook, so the plugin writes them here. The
296
- // serializer only runs once Metro has successfully built + transformed the whole graph
297
- // (a failed build throws before serialization), so this is the clean-build equivalent of
298
- // webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate -- and it runs at
299
- // most once per instance (the rebuild guard above), so this write is the one-shot build's
300
- // 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.
301
227
  if (this.#state !== State.preload) this.#state.write()
302
228
  }
303
229
 
304
230
  #capture(graph, preModules) {
305
231
  const entries = entrySet(graph)
306
- // preModules first (Metro's prepended polyfills/runtime + InitializeCore -- real on-disk
307
- // files that ship in every bundle; only the customSerializer path receives them, and the
308
- // synthetic ones like __prelude__ are skipped by the existsSync guard below). Then the
309
- // 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.
310
234
  if (preModules) this.#captureModules(preModules, entries)
311
235
  this.#captureModules(graph.dependencies.values(), entries)
312
- // AFTER the graph so #seen already holds every graph module: an auto-include that
313
- // did land in the graph this build (asyncRequire does when the app uses dynamic
314
- // import()) is deduped instead of re-recorded.
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.
315
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' })
316
249
  }
317
250
 
318
- // Attest the AUTO_INCLUDES list (see its note above). Skips, per entry: a specifier
319
- // that doesn't resolve from the project (the file genuinely doesn't exist there --
320
- // nothing to attest), a file already captured as a graph module this build, and a
321
- // resolution landing outside the project root (unattestable -- lockfile/bundle keys
322
- // are root-relative; same out-of-scope stance as the load transformer's inScope).
323
- // Everything that IS attested goes through the exact addFile path graph modules use,
324
- // so frozen runs verify these files fail-closed like any other capture.
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.
325
253
  #captureAutoIncludes() {
326
- for (const { specifier, resource } of AUTO_INCLUDES) {
254
+ for (const specifier of AUTO_INCLUDES) {
327
255
  let modPath
328
256
  try {
329
257
  modPath = require.resolve(specifier, { paths: [this.#projectDir] })
330
258
  } catch {
331
- continue // not installed in this project -- nothing to attest
259
+ continue
332
260
  }
333
261
  if (this.#seen.has(modPath)) continue
334
262
  try {
335
263
  this.#state.relative(modPath)
336
264
  } catch {
337
- continue // outside the project root -- unattestable
265
+ continue
338
266
  }
339
267
  this.#seen.add(modPath)
340
- // Real reader for the same reason as #captureModules: this is plugin bookkeeping,
341
- // not a program fs read -- it must not be re-recorded by a --fs patched reader.
268
+ // Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
342
269
  const source = realReadFileSync(modPath)
343
- if (!resource) {
344
- assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${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
345
302
  }
346
- this.#state.addFile(pathToFileURL(modPath).toString(), { source, resource, reason: 'StasisMetro' })
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' })
347
397
  }
348
398
  }
349
399
 
350
400
  #captureModules(modules, entries) {
351
401
  for (const module of modules) {
352
- // Metro keys `graph.dependencies` by the module's absolute path; `module.path` is that
353
- // 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).
354
403
  const modPath = module?.path
355
- // Skip synthetic / virtual modules (Metro injects some with non-disk paths, e.g.
356
- // require.context's `?ctx=` modules and __prelude__): a path that isn't an existing
357
- // absolute file has no bytes to hash. Mirrors webpack's `isAbsolute && existsSync`
358
- // guard and esbuild's `namespace === 'file'` gate.
404
+ // Skip synthetic/virtual modules (require.context's ?ctx=, __prelude__): no on-disk bytes to hash.
359
405
  if (typeof modPath !== 'string' || !isAbsolute(modPath) || !existsSync(modPath)) continue
360
406
 
361
407
  const kind = classifyExtension(modPath, this.#resources)
@@ -367,12 +413,8 @@ export class StasisMetro {
367
413
  }
368
414
  const url = pathToFileURL(modPath).toString()
369
415
 
370
- // Record this module's outgoing resolution edges. Metro keys a module's `dependencies`
371
- // by an opaque hash; the as-written specifier is `dep.data.name` (e.g. './hello' or
372
- // 'react-native') and the resolved target is `dep.absolutePath`. We only attest edges
373
- // whose TARGET is code: resource imports (an asset pulled in for its bytes) are captured
374
- // as files below but carry no import edge, matching webpack/esbuild (`kind === 'code'`).
375
- // 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).
376
418
  for (const dep of module.dependencies?.values?.() ?? []) {
377
419
  const target = dep?.absolutePath
378
420
  const specifier = dep?.data?.name
@@ -385,36 +427,56 @@ export class StasisMetro {
385
427
  if (this.#seen.has(modPath)) continue
386
428
  this.#seen.add(modPath)
387
429
 
388
- // Read the bytes from disk rather than `module.getSource()` -- addFile re-reads disk
389
- // and asserts byte-equality against what we pass, and disk is the thing stasis attests.
390
- // Use the REAL reader (state-util.js), like webpack/esbuild: this capture read is the
391
- // plugin's own bookkeeping, NOT a program fs read. `import { readFileSync } from
392
- // 'node:fs'` (the named binding above, before this fix) is rebound to the patched
393
- // reader by `stasis run --fs`'s syncBuiltinESMExports(), so reading through it would
394
- // re-record the whole Metro module graph into the preload/main bundle -- it belongs to
395
- // 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.
396
432
  const source = realReadFileSync(modPath)
397
- // Code-classified files must be UTF-8. A code extension carrying non-UTF-8 bytes is
398
- // malformed input; refuse it rather than silently encoding it as a resource. Resource-
399
- // 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.
400
434
  if (kind === 'code') {
401
435
  assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
402
436
  }
403
- 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' })
404
438
  }
405
439
  }
406
440
  }
407
441
 
408
- // Idiomatic Metro-config wrapper (cf. withNativeWind / withSentryConfig): returns a new
409
- // config with `serializer.customSerializer` wired so stasis captures the graph + preModules
410
- // and your existing customSerializer (or Metro's default) still produces the bundle. This is
411
- // the recommended surface -- it uses the stable customSerializer (so preModules are covered)
412
- // 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`.
413
446
  export function withStasis(config = {}, options = {}) {
414
- const stasis = new StasisMetro(options)
447
+ const stasis = new StasisMetro(options, { ownsConfig: true })
415
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
+ }
416
477
  return {
417
478
  ...config,
479
+ ...(dropCache && { cacheStores: [] }),
418
480
  serializer: {
419
481
  ...config.serializer,
420
482
  customSerializer: stasis.customSerializer(existing),