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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/stasis-plugins",
3
- "version": "1.0.0-beta.12",
3
+ "version": "1.0.0-beta.13",
4
4
  "description": "Bundler plugins (webpack, esbuild, Rollup, Metro) for @exodus/stasis-core",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,6 +36,6 @@
36
36
  },
37
37
  "homepage": "https://github.com/PreventiveMeasures/stasis#readme",
38
38
  "dependencies": {
39
- "@exodus/stasis-core": "1.0.0-beta.12"
39
+ "@exodus/stasis-core": "1.0.0-beta.13"
40
40
  }
41
41
  }
@@ -30,11 +30,21 @@ function isDependencyCall(callee) {
30
30
  callee.property.type === 'Identifier' && REQUIRE_MEMBERS.has(callee.property.name)
31
31
  }
32
32
 
33
- // The specifier a dependency node holds as a string literal: an import/export's source, a call's
34
- // first argument. A plugin rewriting it replaces the literal, never the node holding it.
33
+ // The string `node` holds as a constant: a literal, a template with no substitutions, or a `+` of
34
+ // such, which Metro's collectDependencies evaluates to the specifier as it does a literal.
35
+ function constantString(node) {
36
+ if (node?.type === 'StringLiteral') return node.value
37
+ if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked ?? undefined
38
+ if (node?.type !== 'BinaryExpression' || node.operator !== '+') return undefined
39
+ const left = constantString(node.left)
40
+ const right = left === undefined ? undefined : constantString(node.right)
41
+ return right === undefined ? undefined : left + right
42
+ }
43
+
44
+ // The specifier a dependency node holds as a constant: an import/export's source, a call's first
45
+ // argument. A plugin rewriting it replaces the literal, never the node holding it.
35
46
  function specifierOf(node) {
36
- const literal = node.type === 'CallExpression' ? node.arguments[0] : node.source
37
- return literal?.type === 'StringLiteral' ? literal.value : undefined
47
+ return constantString(node.type === 'CallExpression' ? node.arguments[0] : node.source)
38
48
  }
39
49
 
40
50
  function asWrittenPlugin({ types: t }) {
@@ -42,10 +52,22 @@ function asWrittenPlugin({ types: t }) {
42
52
  name: 'stasis-as-written',
43
53
  pre(file) {
44
54
  const { slots } = transformContext.getStore()
55
+ let evaluate = false
45
56
  t.traverseFast(file.ast, (node) => {
46
57
  if (node.type === 'CallExpression' ? !isDependencyCall(node.callee) : !DECLARATIONS.has(node.type)) return
47
58
  const written = specifierOf(node)
48
59
  if (written !== undefined) slots.push({ node, written })
60
+ else if (node.type === 'CallExpression' && node.arguments.length > 0) evaluate = true
61
+ })
62
+ // Metro takes any argument Babel evaluates to a string with confidence, one bound to a constant
63
+ // too (`const name = './x'; require(name)`), which only a path, and its scope, can evaluate.
64
+ if (!evaluate) return
65
+ file.path.traverse({
66
+ CallExpression(path) {
67
+ if (!isDependencyCall(path.node.callee) || specifierOf(path.node) !== undefined || path.node.arguments.length === 0) return
68
+ const { confident, value } = path.get('arguments.0').evaluate()
69
+ if (confident && typeof value === 'string') slots.push({ node: path.node, written: value })
70
+ },
49
71
  })
50
72
  },
51
73
  visitor: {
@@ -73,7 +95,8 @@ export function transform(args) {
73
95
  const collect = (result) => {
74
96
  for (const { node, written } of slots) {
75
97
  if (DECLARATIONS.has(node.type) && !kept.has(node)) continue
76
- const left = specifierOf(node)
98
+ // One evaluated through its scope stays as written: no plugin rewrites what isn't a literal.
99
+ const left = specifierOf(node) ?? (node.type === 'CallExpression' ? written : undefined)
77
100
  if (left === undefined) continue
78
101
  if (!context.asWritten.has(left)) context.asWritten.set(left, new Set())
79
102
  context.asWritten.get(left).add(written)
@@ -88,14 +88,18 @@ function resolveBabelTransformer(base, path) {
88
88
  return isAbsolute(path) ? path : createRequire(base.file).resolve(path)
89
89
  }
90
90
 
91
- // Each dependency named by a specifier Babel rewrote, with the ones written in the source. One
92
- // Metro dependency can stand for several, written apart and rewritten alike (`#local/abc` and
93
- // `./src/abc`); a dependency written as Metro names it is left as is.
91
+ // Each dependency as the source wrote it. One named by a specifier Babel rewrote carries the ones
92
+ // written in the source: one Metro dependency can stand for several, written apart and rewritten
93
+ // alike (`#local/abc` and `./src/abc`). One no specifier in the source names carries
94
+ // `stasisInserted`: Babel added it (a helper from @babel/runtime, the JSX runtime), or Metro did (its
95
+ // asyncRequire for an `import()`, an asset's registry). A dependency written as Metro names it is
96
+ // left as is.
94
97
  function withAsWritten(result, asWritten) {
95
- if (asWritten.size === 0 || !Array.isArray(result?.dependencies)) return result
98
+ if (!Array.isArray(result?.dependencies)) return result
96
99
  const dependencies = result.dependencies.map((dep) => {
97
100
  const written = asWritten.get(dep.name)
98
- if (written === undefined || (written.size === 1 && written.has(dep.name))) return dep
101
+ if (written === undefined) return { ...dep, stasisInserted: true }
102
+ if (written.size === 1 && written.has(dep.name)) return dep
99
103
  return { ...dep, stasisAsWritten: [...written] }
100
104
  })
101
105
  return { ...result, dependencies }
package/src/metro.js CHANGED
@@ -3,7 +3,7 @@ import { isUtf8 } from 'node:buffer'
3
3
  import { spawnSync } from 'node:child_process'
4
4
  import { existsSync } from 'node:fs'
5
5
  import { createRequire } from 'node:module'
6
- import { isAbsolute, join } from 'node:path'
6
+ import { dirname, isAbsolute, join } from 'node:path'
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url'
8
8
 
9
9
  import { resolvePluginState } from './plugins.js'
@@ -40,6 +40,23 @@ const AUTO_INCLUDES = [
40
40
  '@react-native-community/cli/setup_env.sh',
41
41
  ]
42
42
 
43
+ // Metro's own transform plugins, as its transform worker names them for its cache key: the file
44
+ // `metro-transform-plugins` resolves to from the project, else through metro and its transform
45
+ // worker, which depend on it (a pnpm install links it only beside them). -> [] where none resolves
46
+ function metroTransformPluginFiles(projectDir) {
47
+ for (const chain of [['metro-transform-plugins'], ['metro', 'metro-transform-worker', 'metro-transform-plugins']]) {
48
+ let file
49
+ try {
50
+ for (const name of chain) file = require.resolve(name, { paths: [file === undefined ? projectDir : dirname(file)] })
51
+ } catch {
52
+ continue
53
+ }
54
+ const { getTransformPluginCacheKeyFiles } = require(file)
55
+ return typeof getTransformPluginCacheKeyFiles === 'function' ? getTransformPluginCacheKeyFiles() : []
56
+ }
57
+ return []
58
+ }
59
+
43
60
  // The worker-side toolchain that only Metro's transform workers load, named in both the
44
61
  // --child-process assert and the transform-cache warning so the two can't describe different sets.
45
62
  const WORKER_TOOLCHAIN = 'babel.config.js, @babel/core, the RN preset + plugins'
@@ -252,17 +269,18 @@ export class StasisMetro {
252
269
  // AFTER the graph so #seen already holds graph modules -- an auto-include that landed in the
253
270
  // graph (asyncRequire, when the app uses dynamic import()) is deduped, not re-recorded.
254
271
  this.#captureAutoIncludes()
272
+ this.#captureTransformPlugins()
255
273
  // AFTER the graph + auto-includes (same dedupe reason): the native pass only adds the native
256
274
  // build-input surface (podspec/gradle/sources) that no module graph carries.
257
275
  this.#captureNativeModules()
258
276
  }
259
277
 
260
- // Shared epilogue for the classify-driven capture paths: record under the given format/resource.
261
- // `format` null/undefined lets addFile pick (loader format for JS unless `inferFormat` is false,
262
- // byte-derived for a resource); addFile already asserts UTF-8 for a non-resource (code) source, so
263
- // no guard is needed here.
264
- #recordCapture(full, source, { format, resource, inferFormat = true }) {
265
- this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason: 'metro' })
278
+ // Shared epilogue for the classify-driven capture paths: record under the given format/resource,
279
+ // attributed to `reason`. `format` null/undefined lets addFile pick (loader format for JS unless
280
+ // `inferFormat` is false, byte-derived for a resource); addFile already asserts UTF-8 for a
281
+ // non-resource (code) source, so no guard is needed here.
282
+ #recordCapture(full, source, { format, resource, inferFormat = true, reason = 'metro' }) {
283
+ this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason })
266
284
  }
267
285
 
268
286
  // Attest the AUTO_INCLUDES list. Per entry, skip: unresolvable, already-seen, or resolving
@@ -293,6 +311,31 @@ export class StasisMetro {
293
311
  }
294
312
  }
295
313
 
314
+ // Attest each of Metro's own transform plugins (metroTransformPluginFiles). Its transform worker
315
+ // hashes every one into its cache key, but loads one only as the build's options call for it
316
+ // (import-export-plugin under `experimentalImportSupport`, inline-requires-plugin under
317
+ // `inlineRequires`, constant-folding-plugin outside dev, normalizePseudoGlobals when minifying), so
318
+ // a capture loads them all, as Metro would: under `stasis run` the loader then records each with
319
+ // what it imports, so a bundle builds under any of those options. Each is recorded besides, as the
320
+ // Node module it is, where no loader records it, attributed to `run` as one Metro loads is: the
321
+ // build's toolchain, none of it the app's. One outside the project root is passed over.
322
+ #captureTransformPlugins() {
323
+ for (const file of metroTransformPluginFiles(this.#projectDir)) {
324
+ if (this.#seen.has(file)) continue
325
+ try {
326
+ this.#state.relative(file)
327
+ } catch {
328
+ continue
329
+ }
330
+ this.#seen.add(file)
331
+ try {
332
+ require(file)
333
+ } catch { /* one that can't load can't run under Metro either: its bytes are still recorded */ }
334
+ // Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
335
+ this.#recordCapture(file, realReadFileSync(file), { reason: 'run' })
336
+ }
337
+ }
338
+
296
339
  // Attest each native dependency's build-input surface. Discovery is `react-native config`; each
297
340
  // dep root is walked (isSkippedNativeWalkDir pruned) and its native sources are attested as code,
298
341
  // other assets as resources, plus each package.json. Node-runnable JS/TS is skipped (already in
@@ -386,9 +429,13 @@ export class StasisMetro {
386
429
  // (`dep.data.name`) by the specifier Babel left; where a plugin rewrote it, the stasis transformer
387
430
  // (./metro-transformer.js) carries the ones written in the source. Code and resource targets alike,
388
431
  // as `stasis bundle --metro` and the esbuild plugin record them: an asset is resolved as code is.
432
+ // One the source doesn't write (`stasisInserted`: a Babel helper, the JSX runtime, Metro's
433
+ // asyncRequire) is no edge, as `stasis bundle --metro` has none: its file is carried, as every
434
+ // module of the graph is, and under load Metro resolves it unasserted.
389
435
  #edges(module) {
390
436
  const edges = []
391
437
  for (const dep of module.dependencies?.values?.() ?? []) {
438
+ if (dep?.data?.stasisInserted === true) continue
392
439
  const target = dep?.absolutePath
393
440
  if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
394
441
  if (classifyExtension(target, this.#resources) === 'unknown') continue