@exodus/stasis-plugins 1.0.0-beta.15 → 1.0.0-beta.17

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,41 +1,34 @@
1
1
  import assert from 'node:assert/strict'
2
- import { Buffer, 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
6
  import { dirname, isAbsolute, join, resolve } from 'node:path'
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url'
8
8
 
9
- import { resolvePluginState, unsupportedExtension } from './plugins.js'
9
+ import { assertCodeUtf8, classifyOrThrow, resolvePluginState } from './plugins.js'
10
10
  import { AUTO_INCLUDES, FILE_STAT, RN_CORE_INCLUDE_FILES, bundleDirHost, looksNative, metroAssetFiles, nativeInputs } from './metro-native.js'
11
11
  import { diskHost } from '@exodus/stasis-core/host'
12
12
  import { State } from '@exodus/stasis-core/state'
13
+ // realReadFileSync throughout: the plugin's own reads, which --fs must not record as the program's.
13
14
  import { inScope, realReadFileSync } from '@exodus/stasis-core/state-util'
14
15
  import { classifyExtension, classifyFormat, isPathWithin, nodeExtFormat, splitNodeModulesPath } from '@exodus/stasis-core/util'
15
16
 
16
17
  const require = createRequire(import.meta.url)
17
18
 
18
- // The companion worker transformer withStasis() wires as Metro's `transformerPath`.
19
19
  const TRANSFORMER = fileURLToPath(new URL('./metro-transformer.js', import.meta.url))
20
20
 
21
- // A graph module's absolute path (graph modules and preModules both carry it), or undefined for a
22
- // synthetic/virtual one (require.context's ?ctx=, __prelude__), with no on-disk bytes to hash.
21
+ // A graph module's absolute path, or undefined for a virtual one (require.context's ?ctx=, __prelude__).
23
22
  function diskPath(module) {
24
23
  const modPath = module?.path
25
24
  return typeof modPath === 'string' && isAbsolute(modPath) && existsSync(modPath) ? modPath : undefined
26
25
  }
27
26
 
28
- // Metro's `graph.entryPoints` is a Set now, an Array before ~0.71; normalize to a Set.
29
- function entrySet(graph) {
30
- const ep = graph?.entryPoints
31
- if (ep instanceof Set) return ep
32
- if (Array.isArray(ep)) return new Set(ep)
33
- return new Set()
34
- }
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 : [])
35
29
 
36
- // Metro's own transform plugins, as its transform worker names them for its cache key: the file
37
- // `metro-transform-plugins` resolves to from the project, else through metro and its transform
38
- // worker, which depend on it (a pnpm install links it only beside them). -> [] where none resolves
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).
39
32
  function metroTransformPluginFiles(projectDir) {
40
33
  for (const chain of [['metro-transform-plugins'], ['metro', 'metro-transform-worker', 'metro-transform-plugins']]) {
41
34
  let file
@@ -50,12 +43,9 @@ function metroTransformPluginFiles(projectDir) {
50
43
  return []
51
44
  }
52
45
 
53
- // The worker-side toolchain that only Metro's transform workers load, named in both the
54
- // --child-process assert and the transform-cache warning so the two can't describe different sets.
55
46
  const WORKER_TOOLCHAIN = 'babel.config.js, @babel/core, the RN preset + plugins'
56
47
 
57
- // Run `react-native config` (RN's autolinking resolver) for native deps. Null when the RN CLI
58
- // 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.
59
49
  function loadReactNativeConfig(projectDir) {
60
50
  let cliPath
61
51
  try {
@@ -63,27 +53,14 @@ function loadReactNativeConfig(projectDir) {
63
53
  } catch {
64
54
  return null
65
55
  }
66
- // Run the child as vanilla Node: strip stasis's env and any inherited loader, else it joins
67
- // this capture nondeterministically or aborts on a config it can't satisfy.
68
- const env = { ...process.env }
69
- for (const key of Object.keys(env)) {
70
- if (key.startsWith('EXODUS_STASIS_')) delete env[key]
71
- }
72
- delete env.NODE_OPTIONS
73
- const res = spawnSync(process.execPath, [cliPath, 'config'], {
74
- cwd: projectDir,
75
- encoding: 'utf8',
76
- maxBuffer: 128 * 1024 * 1024, // a large monorepo's config JSON can be many MB
77
- env,
78
- })
79
- if (res.error) {
80
- throw new Error(`StasisMetro: failed to run 'react-native config': ${res.error.message}`, { cause: res.error })
81
- }
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 })
82
60
  if (res.status !== 0) {
83
61
  const detail = (res.stderr || '').trim()
84
62
  throw new Error(
85
- `StasisMetro: 'react-native config' exited ${res.status}${res.signal ? ` on signal ${res.signal}` : ''}` +
86
- `${detail ? `: ${detail}` : ''}`
63
+ `StasisMetro: 'react-native config' exited ${res.status}${res.signal ? ` on signal ${res.signal}` : ''}${detail ? `: ${detail}` : ''}`
87
64
  )
88
65
  }
89
66
  try {
@@ -93,28 +70,27 @@ function loadReactNativeConfig(projectDir) {
93
70
  }
94
71
  }
95
72
 
96
- // 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.
97
74
  function isNativeDependency(dep) {
98
75
  const platforms = dep?.platforms
99
- if (!platforms || typeof platforms !== 'object') return false
100
- return Object.values(platforms).some((p) => p != null)
76
+ return typeof platforms === 'object' && platforms !== null && Object.values(platforms).some((p) => p != null)
101
77
  }
102
78
 
103
- // Metro has no public default-serializer export; reproduce its default output via internal
104
- // baseJSBundle + bundleToString. Path moved: metro/src/* through ~0.82, metro/private/* from
105
- // 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.
106
80
  let metroDefault
107
81
  function metroDefaultSerializer() {
108
82
  if (metroDefault) return metroDefault
109
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
+ })
110
88
  let baseJSBundle, bundleToString
111
89
  try {
112
- baseJSBundle = require(`${M}/src/DeltaBundler/Serializers/baseJSBundle`)
113
- bundleToString = require(`${M}/src/lib/bundleToString`)
90
+ [baseJSBundle, bundleToString] = load('src')
114
91
  } catch {
115
92
  try {
116
- baseJSBundle = require(`${M}/private/DeltaBundler/Serializers/baseJSBundle`)
117
- bundleToString = require(`${M}/private/lib/bundleToString`)
93
+ [baseJSBundle, bundleToString] = load('private')
118
94
  } catch (cause) {
119
95
  throw new Error(
120
96
  "StasisMetro: couldn't load Metro's default serializer (tried metro/src and " +
@@ -123,53 +99,29 @@ function metroDefaultSerializer() {
123
99
  )
124
100
  }
125
101
  }
126
- // Metro ships CJS; require exposes the fn as the namespace or under `.default`.
127
- baseJSBundle = baseJSBundle.default ?? baseJSBundle
128
- bundleToString = bundleToString.default ?? bundleToString
129
- metroDefault = (entryPoint, preModules, graph, options) =>
130
- bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
102
+ metroDefault = (entryPoint, preModules, graph, options) => bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
131
103
  return metroDefault
132
104
  }
133
105
 
134
- // Stasis capture plugin for Metro (React Native). Hooks Metro's serializer, which runs ONCE in
135
- // the MAIN process with the full module graph -- a per-worker transformer's process-local State
136
- // could never be merged. This is the CAPTURE half; the companion ./metro-transformer.js runs in
137
- // the workers, recording each import as written and serving the LOAD half, and withStasis wires
138
- // both. Under bundle=load this serializer records nothing: Metro resolved every import itself,
139
- // so it asserts each edge against the bundle (see #assertEdges).
140
- // Capture is one-shot (dev-server rebuilds refused, see #run) and REQUIRES --child-process so the
141
- // worker-side toolchain (babel, RN preset) is attested (enforced in the constructor). withStasis
142
- // wires the stable customSerializer -- the only surface that receives preModules; serializerHook
143
- // is a lower-coverage fallback that never sees them. Prefer withStasis for a second reason too: a
144
- // hand-wired serializer leaves the Metro config -- and therefore its transform cache and
145
- // transformerPath -- out of 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.
146
109
  export class StasisMetro {
147
110
  #seen = new Set()
148
- // The project root's real path, once a consumed resource needs it (#consumedResources).
149
111
  #realRoot
150
- // Under load, the bundle's files as a directory listing, once an asset needs them (#bundleDirHost).
151
112
  #bundleDirs
152
113
  #state
153
114
  #resources
154
-
155
- // 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.
156
116
  #projectDir = process.cwd()
157
-
158
- // Whether this instance already captured; the dev-server rebuild guard in #run trips on the second.
159
117
  #ran = false
160
-
161
- // Set by withStasis, which returns the Metro config and can therefore drop its transform cache;
162
- // a hand-wired customSerializer/serializerHook can't, so #run warns instead. Internal wiring, in
163
- // the second options bag like StasisEsbuild's -- not a user-facing option.
164
118
  #ownsConfig
165
119
 
166
120
  constructor(options = {}, { ownsConfig = false } = {}) {
167
121
  const { state } = resolvePluginState('StasisMetro', options, process.cwd())
168
- this.#state = state // null when the plugin should be inert (Rule 0 or Rule 7)
122
+ this.#state = state
169
123
  this.#ownsConfig = ownsConfig
170
- // A capture-that-writes REQUIRES --child-process: the worker toolchain (babel, RN preset) is
171
- // observed only in workers. Gate on writeLockfile||writeBundle so it's demanded only when
172
- // 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.
173
125
  if (state && (state.config.writeLockfile || state.config.writeBundle)) {
174
126
  assert.ok(
175
127
  state.config.childProcess,
@@ -178,69 +130,52 @@ export class StasisMetro {
178
130
  'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
179
131
  'or "childProcess": true in stasis.config.json.'
180
132
  )
181
- // Opt this build's children into the loader's SIGTERM shard flush (handler in hooks.js) so
182
- // jest-worker forceExit doesn't silently drop a non-draining worker's shard. Key on the
183
- // PRELOAD's mode where one exists (the shard channel follows it, not a Rule-6 sidecar), and
184
- // 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.
185
135
  const channel = State.preload ?? state
186
136
  if (channel.config.writeLockfile || channel.config.writeBundle) {
187
137
  process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1'
188
138
  }
189
139
  }
190
- // Cache the resolved resources Set for the per-file classify hot path.
191
140
  this.#resources = state?.config.resources ?? new Set()
192
141
  }
193
142
 
194
- // True when this build's guarantees depend on Metro actually TRANSFORMING files: a capture records
195
- // what the workers load, frozen checks it, and load REPLAYS it -- all three break if a cached
196
- // transform skips the work. False only when inert or attesting nothing ('none'/'ignore', what
197
- // 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.
198
145
  get needsTransforms() {
199
146
  const config = this.#state?.config
200
- if (!config) return false
201
- return config.useLockfile || config.bundle
147
+ return config !== undefined && (config.useLockfile || config.bundle)
202
148
  }
203
149
 
204
- // Build a Metro `serializer.customSerializer`: captures the graph + preModules (which the
205
- // experimental hook never sees), then delegates to base or reproduces Metro's default output.
206
150
  customSerializer(baseSerializer = undefined) {
207
151
  if (baseSerializer !== undefined && typeof baseSerializer !== 'function') {
208
152
  throw new TypeError('StasisMetro.customSerializer(base?): base must be a function or omitted')
209
153
  }
210
- // Inert (#state === null): hand the base straight back -- the wrapper below would otherwise
211
- // 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.
212
155
  if (!this.#state) return baseSerializer
213
156
  return (entryPoint, preModules, graph, options) => {
214
157
  this.#run(graph, preModules)
215
- const serialize = baseSerializer ?? metroDefaultSerializer()
216
- return serialize(entryPoint, preModules, graph, options)
158
+ return (baseSerializer ?? metroDefaultSerializer())(entryPoint, preModules, graph, options)
217
159
  }
218
160
  }
219
161
 
220
- // `serializer.experimentalSerializerHook`: observation-only, and LOWER COVERAGE -- Metro never
221
- // passes preModules here, so the prepended polyfills/runtime go unattested. Prefer customSerializer.
222
162
  serializerHook = (graph, _delta) => {
223
163
  this.#run(graph)
224
164
  }
225
165
 
226
166
  #run(graph, preModules) {
227
167
  if (!this.#state) return
228
- // bundle=load: assert, don't record (the worker transformer serves the bundle's bytes). Checked
229
- // BEFORE the one-shot guard -- load captures nothing and legitimately re-serializes under a dev server.
168
+ // Before the one-shot guard: load records nothing, and may re-serialize under a dev server.
230
169
  if (this.#state.config.loadBundle) {
231
170
  this.#assertEdges(graph, preModules)
232
171
  return
233
172
  }
234
- // Watch/dev-server capture is unsupported: dedupe is keyed by PATH not content, so a rebuild
235
- // with changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Refuse
236
- // the second serialization loudly (the first rebuild is the earliest refusal point Metro allows).
237
173
  if (this.#ran) {
238
174
  throw new Error(
239
175
  'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
240
176
  )
241
177
  }
242
178
  this.#ran = true
243
- // Keyed on ownership: a withStasis-built instance already dropped the stores (see withStasis).
244
179
  // Nothing here can detect a cache hit, so say it once rather than silently under-attest.
245
180
  if (!this.#ownsConfig && this.needsTransforms) {
246
181
  console.warn(
@@ -251,37 +186,27 @@ export class StasisMetro {
251
186
  )
252
187
  }
253
188
  this.#capture(graph, preModules)
254
-
255
- // The preload writes itself (hooks.js); standalone/sidecar States are written here. Reaching
256
- // 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.
257
190
  if (this.#state !== State.preload) this.#state.write()
258
191
  }
259
192
 
260
193
  #capture(graph, preModules) {
261
194
  const entries = entrySet(graph)
262
195
  const platform = graph.transformOptions?.platform ?? null
263
- // preModules first (Metro's prepended polyfills/runtime; synthetic ones like __prelude__ are
264
- // skipped by the existsSync guard below), then the app graph.
265
196
  if (preModules) this.#captureModules(preModules, entries, platform)
266
197
  this.#captureModules(graph.dependencies.values(), entries, platform)
267
198
  this.#captureAutoIncludes()
268
199
  this.#captureTransformPlugins()
269
- // AFTER the graph + auto-includes (same dedupe reason): the native pass only adds the native
270
- // build-input surface (podspec/gradle/sources) that no module graph carries.
200
+ // Last, past what is already captured: the native build inputs no module graph carries.
271
201
  this.#captureNativeModules()
272
202
  }
273
203
 
274
- // Shared epilogue for the classify-driven capture paths: record under the given format/resource,
275
- // attributed to `reason`. `format` null/undefined lets addFile pick (loader format for JS unless
276
- // `inferFormat` is false, byte-derived for a resource); addFile already asserts UTF-8 for a
277
- // non-resource (code) source, so no guard is needed here.
278
204
  #recordCapture(full, source, { format, resource, inferFormat = true, reason = 'metro' }) {
279
205
  this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason })
280
206
  }
281
207
 
282
- // Attest the AUTO_INCLUDES list (./metro-native.js, shared with `stasis bundle --metro`). Per entry,
283
- // skip: unresolvable, already-seen, or resolving outside the project root (keys are root-relative).
284
- // 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.
285
210
  #captureAutoIncludes() {
286
211
  for (const specifier of AUTO_INCLUDES) {
287
212
  let modPath
@@ -292,24 +217,14 @@ export class StasisMetro {
292
217
  }
293
218
  if (this.#seen.has(modPath) || !this.#within(modPath)) continue
294
219
  this.#seen.add(modPath)
295
- // Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
296
220
  const source = realReadFileSync(modPath)
297
- // Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
298
- // the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
299
- // would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
300
221
  const format = classifyFormat(modPath, { content: source })
301
222
  this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false, reason: 'run' })
302
223
  }
303
224
  }
304
225
 
305
- // Attest each of Metro's own transform plugins (metroTransformPluginFiles). Its transform worker
306
- // hashes every one into its cache key, but loads one only as the build's options call for it
307
- // (import-export-plugin under `experimentalImportSupport`, inline-requires-plugin under
308
- // `inlineRequires`, constant-folding-plugin outside dev, normalizePseudoGlobals when minifying), so
309
- // a capture loads them all, as Metro would: under `stasis run` the loader then records each with
310
- // what it imports, so a bundle builds under any of those options. Each is recorded besides, as the
311
- // Node module it is, where no loader records it, attributed to `run` as one Metro loads is: the
312
- // build's toolchain, none of it the app's. One outside the project root is passed over.
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`.
313
228
  #captureTransformPlugins() {
314
229
  for (const file of metroTransformPluginFiles(this.#projectDir)) {
315
230
  if (this.#seen.has(file) || !this.#within(file)) continue
@@ -317,38 +232,27 @@ export class StasisMetro {
317
232
  try {
318
233
  require(file)
319
234
  } catch { /* one that can't load can't run under Metro either: its bytes are still recorded */ }
320
- // Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
321
235
  this.#recordCapture(file, realReadFileSync(file), { reason: 'run' })
322
236
  }
323
237
  }
324
238
 
325
- // Attest each native dependency's build-input surface. Discovery is `react-native config`; each
326
- // dep root is walked (isSkippedNativeWalkDir pruned) and its native sources are attested as code,
327
- // other assets as resources, plus each package.json. Node-runnable JS/TS is skipped (already in
328
- // Metro's graph). Skipped when the RN CLI is absent or a root is outside root.
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.
329
241
  #captureNativeModules() {
330
242
  const config = loadReactNativeConfig(this.#projectDir)
331
243
  if (!config) return
332
244
  const roots = new Set()
333
- // Autolinked native deps: linked into the app regardless of whether their JS is imported.
334
245
  for (const dep of Object.values(config.dependencies ?? {})) {
335
246
  if (isNativeDependency(dep) && typeof dep.root === 'string' && isAbsolute(dep.root)) roots.add(dep.root)
336
247
  }
337
- // Native modules the app imports but autolinking never reports (manually integrated via Podfile).
338
248
  for (const root of this.#reachedNativePackageRoots()) roots.add(root)
339
- // RN CORE isn't a `dependencies` entry (config reports only reactNativePath) and autolinking
340
- // never lists it, but its native source (React/, ReactCommon/, ReactAndroid/, sdks/, ...) is a
341
- // build input like any other dep's -- walk it in full, the same way.
342
249
  const rnPath = typeof config.reactNativePath === 'string' && isAbsolute(config.reactNativePath) ? config.reactNativePath : null
343
250
  if (rnPath !== null) roots.add(rnPath)
344
- // One outside the project root is unattestable.
345
251
  for (const root of roots) if (this.#within(root)) this.#captureNativeTree(root)
346
- // RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
252
+ // RN core's `.js` build scripts, which the walk leaves to Metro but its graph never has.
347
253
  if (rnPath !== null && this.#within(rnPath)) for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
348
254
  }
349
255
 
350
- // Attest a single vetted file (RN_CORE_INCLUDE_FILES) the tree walk skips; absent -> skipped.
351
- // Tagged via the shared classifier like every other path (a `.js` build script -> code).
352
256
  #captureNativeFile(full) {
353
257
  if (this.#seen.has(full)) return
354
258
  let source
@@ -362,23 +266,13 @@ export class StasisMetro {
362
266
  this.#recordCapture(full, source, { format, resource: format === undefined })
363
267
  }
364
268
 
365
- // node_modules roots the graph reached that also ship native code (podspec or ios/android dir):
366
- // the manually-integrated native modules `react-native config` omits. Derived from #seen; RN core
367
- // is skipped here because it's added to the walk explicitly (via reactNativePath).
269
+ // RN core is walked as reactNativePath.
368
270
  #reachedNativePackageRoots() {
369
- const pkgRoots = new Set()
370
- for (const abs of this.#seen) {
371
- const nm = splitNodeModulesPath(abs)
372
- if (!nm || nm.name === 'react-native') continue
373
- pkgRoots.add(nm.dir)
374
- }
375
- return [...pkgRoots].filter((root) => looksNative(root, diskHost))
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))
376
273
  }
377
274
 
378
- // Attest the native build inputs of the package at `root` (nativeInputs, shared with `stasis bundle
379
- // --metro`), but what is already captured as a graph module or auto-include, each in the format it
380
- // gives (none for a codegen source, as Metro's code has none, so none is inferred). Real readers:
381
- // plugin bookkeeping, not re-recorded by --fs.
275
+ // In the format nativeInputs gives (none for a codegen source, as Metro's code has none, so none is inferred).
382
276
  #captureNativeTree(root) {
383
277
  for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
384
278
  this.#seen.add(file)
@@ -386,10 +280,7 @@ export class StasisMetro {
386
280
  }
387
281
  }
388
282
 
389
- // bundle=load: Metro resolved every import itself (the loader and the esbuild/webpack plugins
390
- // take theirs from the bundle), so before any output each edge must be the one the bundle attests
391
- // -- the edges a capture records, from the same modules.
392
- // A per-platform edge (a `stasis bundle --metro --platforms=...` bundle added to) is checked for the
283
+ // bundle=load: Metro resolved every import itself, so each edge must be the one the bundle attests, for the
393
284
  // platform Metro builds.
394
285
  #assertEdges(graph, preModules) {
395
286
  const platform = graph.transformOptions?.platform ?? undefined
@@ -398,18 +289,15 @@ export class StasisMetro {
398
289
  if (modPath === undefined) continue
399
290
  const url = pathToFileURL(modPath).toString()
400
291
  for (const [specifier, target] of this.#edges(module)) this.#state.assertImport(url, specifier, target, { platform })
401
- this.#assertConsumed(module, modPath)
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
+ }
402
295
  this.#assertAssetFiles(module, modPath, platform ?? null)
403
296
  }
404
297
  }
405
298
 
406
- // A module's outgoing edges, keyed as written, to `dep.absolutePath`. Metro names a dependency
407
- // (`dep.data.name`) by the specifier Babel left; where a plugin rewrote it, the stasis transformer
408
- // (./metro-transformer.js) carries the ones written in the source. Code and resource targets alike,
409
- // as `stasis bundle --metro` and the esbuild plugin record them: an asset is resolved as code is.
410
- // One the source doesn't write (`stasisInserted`: a Babel helper, the JSX runtime, Metro's
411
- // asyncRequire) is no edge, as `stasis bundle --metro` has none: its file is carried, as every
412
- // module of the graph is, and under load Metro resolves it unasserted.
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.
413
301
  #edges(module) {
414
302
  const edges = []
415
303
  for (const dep of module.dependencies?.values?.() ?? []) {
@@ -428,41 +316,19 @@ export class StasisMetro {
428
316
  for (const module of modules) {
429
317
  const modPath = diskPath(module)
430
318
  if (modPath === undefined) continue
431
-
432
- const kind = classifyExtension(modPath, this.#resources)
433
- if (kind === 'unknown') {
434
- throw new Error(unsupportedExtension('StasisMetro', `'${modPath}'`))
435
- }
319
+ const kind = classifyOrThrow('StasisMetro', modPath, this.#resources)
436
320
  const url = pathToFileURL(modPath).toString()
437
321
  for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
438
-
439
- if (this.#seen.has(modPath)) continue
440
- this.#seen.add(modPath)
441
-
442
- // Read from disk, not module.getSource() -- addFile re-reads disk and asserts byte-equality.
443
- // Real reader (state-util.js): a --fs-patched readFileSync would re-record the graph into the preload.
444
- const source = realReadFileSync(modPath)
445
- // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
446
- if (kind === 'code') {
447
- assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
448
- }
449
- // No format for a code file: Metro transforms it from source, so Node's module/commonjs isn't
450
- // what runs. But the one its name alone gives it (.json, .mjs, .cjs: nodeExtFormat), as Node
451
- // loads it too, which a --fs read of it records besides (a resource still takes its
452
- // byte-derived format).
453
- const format = kind === 'code' ? nodeExtFormat(modPath) : undefined
454
- this.#state.addFile(url, { source, format, isEntry: entries.has(modPath), resource: kind === 'resource', inferFormat: false, reason: 'metro' })
455
- this.#captureConsumed(module, modPath)
322
+ if (!this.#captureRead(modPath, kind, entries.has(modPath))) continue
323
+ for (const { file } of this.#consumedResources(module, modPath)) this.#captureRead(file, 'resource')
456
324
  this.#captureAssetFiles(module, modPath, platform)
457
325
  }
458
326
  }
459
327
 
460
- // Whether `path` lies within the project root, as the State's root-relative keys need.
461
328
  #within(path) {
462
329
  return isPathWithin(this.#state.root, path)
463
330
  }
464
331
 
465
- // `path`'s real path, where it lies within the project root (not linked out of it), else null.
466
332
  #realWithin(path) {
467
333
  let real
468
334
  try {
@@ -474,61 +340,42 @@ export class StasisMetro {
474
340
  return this.#within(real) && isPathWithin(this.#realRoot, real) ? real : null
475
341
  }
476
342
 
477
- // The resources a Babel plugin consumed in transforming `module`, read and inlined in their imports'
478
- // stead (`require('./messages.po')`, @exodus/i18n's plugin-inline-po), as the stasis transformer
479
- // carries them (`stasisConsumed`): no module of Metro's, so no edge, but the build's input all the
480
- // same. One named by path, as such a plugin reads it, from the module's dir, by its real path, as
481
- // Metro names its modules. One naming code (an import a plugin split into others, as
482
- // babel-plugin-lodash does), no file, or one outside the project root is passed over; one the
483
- // resources don't carry is refused. -> each as { file: its real path, read: the path read }
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 }
484
346
  * #consumedResources(module, modPath) {
485
347
  for (const specifier of module.output?.[0]?.data?.stasisConsumed ?? []) {
486
348
  if (typeof specifier !== 'string' || !(isAbsolute(specifier) || /^\.\.?\//u.test(specifier))) continue
487
349
  const read = resolve(dirname(modPath), specifier)
488
350
  const file = diskHost.stat(read)?.isFile() ? this.#realWithin(read) : null
489
351
  if (file === null) continue
490
- const kind = classifyExtension(file, this.#resources)
491
- if (kind === 'code') continue
492
- if (kind === 'unknown') {
493
- throw new Error(unsupportedExtension('StasisMetro', `'${file}', which a Babel plugin inlines into '${modPath}'`))
494
- }
352
+ if (classifyOrThrow('StasisMetro', file, this.#resources, `'${file}', which a Babel plugin inlines into '${modPath}'`) === 'code') continue
495
353
  yield { file, read }
496
354
  }
497
355
  }
498
356
 
499
- // Capture: each, attributed to `metro`, as `stasis bundle --metro` records them under `bundle:metro`.
500
- #captureConsumed(module, modPath) {
501
- for (const { file } of this.#consumedResources(module, modPath)) this.#captureRead(file, 'resource')
502
- }
503
-
504
- // Capture `path`, a file Metro's build reads that its graph doesn't carry, once, attributed to
505
- // `metro`, as the `kind` classifyExtension gives it.
506
- #captureRead(path, kind) {
507
- if (this.#seen.has(path)) return
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
508
362
  this.#seen.add(path)
509
- // Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
510
- this.#recordCapture(path, realReadFileSync(path), { format: kind === 'code' ? nodeExtFormat(path) : undefined, resource: kind === 'resource', inferFormat: false })
511
- }
512
-
513
- // Load: the Babel plugin reads each from disk, past the stasis transformer (#assertOnDisk).
514
- #assertConsumed(module, modPath) {
515
- for (const files of this.#consumedResources(module, modPath)) {
516
- this.#assertOnDisk(files, { what: `which a Babel plugin inlines into '${modPath}'`, servedBy: ['sync', 'async'] })
517
- }
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
518
369
  }
519
370
 
520
- // The files of the asset module `module` Metro builds, by the names it reads them by (#assetDirHost):
521
- // as Metro reads them to build it (its `scales` and `hash`) and copies them out with it, it and its
522
- // other scales (`logo@2x.png`, `logo@3x.png`; metroAssetFiles, Metro's own rule for `platform`). No
523
- // import names one, so no edge. -> their paths, none for a module no asset
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.
524
373
  #assetFiles(module, modPath, platform) {
525
374
  return module.output?.[0]?.type === 'js/module/asset' ? metroAssetFiles(modPath, platform, this.#assetDirHost()) : []
526
375
  }
527
376
 
528
- // The directory listing Metro finds an asset's files in (fs.promises.readdir): the disk's, but under
529
- // load with --fs=async, which serves it, the bundle's where it has one (a file added on disk since
530
- // is no file of the asset Metro builds), each name in it a file where it is one on disk or the bundle
531
- // serves one (gone from disk, it holds no drift).
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).
532
379
  #assetDirHost() {
533
380
  const state = this.#state
534
381
  if (!state.config.loadBundle || state.config.fs !== 'async') return diskHost
@@ -538,30 +385,22 @@ export class StasisMetro {
538
385
  }
539
386
  }
540
387
 
541
- // The files the bundle carries, as a directory listing of them (`metroAssetFiles`' host): an asset's
542
- // files as captured.
543
388
  #bundleDirHost() {
544
- this.#bundleDirs ??= bundleDirHost(this.#state)
545
- return this.#bundleDirs
389
+ return (this.#bundleDirs ??= bundleDirHost(this.#state))
546
390
  }
547
391
 
548
- // Capture: each but the module itself, attributed to `metro`, as `stasis bundle --metro` records them:
549
- // by the name Metro reads it by, with the bytes it reads there (one linked in, its target's), so a
550
- // load holds that name to them. One linked from outside the project is passed over.
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).
551
394
  #captureAssetFiles(module, modPath, platform) {
552
395
  for (const read of this.#assetFiles(module, modPath, platform)) {
553
396
  const kind = classifyExtension(read, this.#resources)
554
- // One of a kind unknown, as the module itself, which the graph capture refused.
555
397
  if (kind !== 'unknown' && this.#realWithin(read) !== null) this.#captureRead(read, kind)
556
398
  }
557
399
  }
558
400
 
559
- // Load: Metro's asset transformer reads each of an asset's files from disk (fs.promises), the module's
560
- // own too, past the stasis transformer, which serves the module's `data` that it never reads. Each it
561
- // reads on disk must be the bundle's under the name it reads it by (#assertOnDisk: a scale added since
562
- // the capture, or one a symlink redirects, reads bytes the bundle doesn't carry there); and each the
563
- // bundle carries must be among those it reads (one gone builds another asset, its `scales` and `hash`,
564
- // from what remains) -- unless --fs=async serves them.
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.
565
404
  #assertAssetFiles(module, modPath, platform) {
566
405
  const read = this.#assetFiles(module, modPath, platform)
567
406
  if (read.length === 0) return
@@ -576,12 +415,8 @@ export class StasisMetro {
576
415
  }
577
416
  }
578
417
 
579
- // Load: a file Metro's build reads from disk by `read`, past the stasis transformer, which serves only
580
- // the module it transforms. One in scope (as the transformer's: any under scope=full, else one in
581
- // node_modules) is held to the bundle's bytes for `file` (getFile throws on one it doesn't carry),
582
- // fail closed on drift -- unless --fs, in a mode of `servedBy` (async patches fs.promises too), serves
583
- // the read from the bundle itself, which it does by the path read (not a symlink to the file the
584
- // bundle carries).
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.
585
420
  #assertOnDisk({ file, read }, { what, servedBy }) {
586
421
  const state = this.#state
587
422
  const url = pathToFileURL(file).toString()
@@ -596,57 +431,31 @@ export class StasisMetro {
596
431
  }
597
432
  }
598
433
 
599
- // Idiomatic Metro-config wrapper: returns a new config with `serializer.customSerializer` wired
600
- // so stasis captures the graph + preModules while your existing serializer (or Metro's default)
601
- // still produces the bundle, with Metro's transform cache dropped so the workers really run, and
602
- // with Metro's transformer run through stasis's (./metro-transformer.js).
603
- // 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.
604
436
  export function withStasis(config = {}, options = {}) {
605
437
  const stasis = new StasisMetro(options, { ownsConfig: true })
606
438
  const existing = config.serializer?.customSerializer ?? undefined
607
- // Metro's transform cache is a CORRECTNESS hazard here, not a speed knob: it decides which modules
608
- // Metro itself LOADS. On a cache hit Metro returns the stored result without calling a worker, so
609
- // the worker-side toolchain is never loaded in any process and never reaches the root -- exactly
610
- // what the constructor's --child-process assert exists to guarantee. The default store is a
611
- // FileStore in the OS tmpdir shared with every other Metro run, so ONE earlier `metro build`/dev
612
- // server silently decides what gets attested: a capture over a warm cache drops the toolchain
613
- // (exit 0, no warning) and a later frozen run rejects the lockfile it wrote, while a frozen run
614
- // over a warm cache passes vacuously (it verifies transforms nobody performed).
615
- //
616
- // Dropped in EVERY active mode, load included, so the module set can't diverge between record and
617
- // replay. `new Transformer` calls getTransformCacheKey() only when the cache is enabled
618
- // (metro/src/DeltaBundler/Transformer.js: `this._cache.isDisabled ? '' : ...`), and that call tree
619
- // resolves the transformer plus its plugins' cache-key files. Disabled during capture, those edges
620
- // are never recorded; left enabled under load they resolve for real and getImport fails closed on
621
- // a bundle that cannot know them ("Cannot find module 'metro-transform-worker' imported from
622
- // .../getTransformCacheKey.js" -- Metro reports it as "Failed to construct transformer").
623
- //
624
- // Slower, but the loaded set stops depending on what ran before. These builds are one-shot anyway.
625
- const dropCache = stasis.needsTransforms
626
- // `undefined` is Metro's own default store, which the user never chose; an array or a
627
- // `(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.
628
444
  const stores = config.cacheStores
629
- if (dropCache && (typeof stores === 'function' || stores?.length > 0)) {
445
+ if (active && (typeof stores === 'function' || stores?.length > 0)) {
630
446
  console.warn(
631
447
  '[stasis] StasisMetro: ignoring the `cacheStores` in your Metro config for this build -- a cached ' +
632
448
  'transform skips the worker, leaving the toolchain it loads unattested'
633
449
  )
634
450
  }
635
- // The workers run the project's transformer through stasis's, which records each import as
636
- // written, before Babel rewrites it, and serves bundle=load: handed over in `transformer`, which
637
- // Metro passes to each worker's transformer as is. In every active mode, load included, so the
638
- // modules a worker loads are the same when recorded and when replayed.
639
- const transformer = stasis.needsTransforms && {
640
- transformerPath: TRANSFORMER,
641
- transformer: { ...config.transformer, stasisBaseTransformerPath: config.transformerPath },
642
- }
451
+ // Metro hands `transformer` to each worker's transformer as is.
643
452
  return {
644
453
  ...config,
645
- ...(dropCache && { cacheStores: [] }),
646
- ...transformer,
647
- serializer: {
648
- ...config.serializer,
649
- customSerializer: stasis.customSerializer(existing),
650
- },
454
+ ...(active && {
455
+ cacheStores: [],
456
+ transformerPath: TRANSFORMER,
457
+ transformer: { ...config.transformer, stasisBaseTransformerPath: config.transformerPath },
458
+ }),
459
+ serializer: { ...config.serializer, customSerializer: stasis.customSerializer(existing) },
651
460
  }
652
461
  }