@exodus/stasis-plugins 1.0.0-beta.11 → 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 +6 -3
- package/src/metro-babel-transformer.js +109 -0
- package/src/metro-native.js +99 -0
- package/src/metro-transformer.js +76 -24
- package/src/metro.js +135 -86
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis-plugins",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
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": {
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
"./esbuild": "./src/esbuild.js",
|
|
9
9
|
"./rollup": "./src/rollup.js",
|
|
10
10
|
"./metro": "./src/metro.js",
|
|
11
|
-
"./metro-transformer": "./src/metro-transformer.js"
|
|
11
|
+
"./metro-transformer": "./src/metro-transformer.js",
|
|
12
|
+
"./metro-native": "./src/metro-native.js"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src/esbuild.js",
|
|
15
16
|
"src/metro.js",
|
|
17
|
+
"src/metro-babel-transformer.js",
|
|
18
|
+
"src/metro-native.js",
|
|
16
19
|
"src/metro-transformer.js",
|
|
17
20
|
"src/plugins.js",
|
|
18
21
|
"src/rollup.js",
|
|
@@ -33,6 +36,6 @@
|
|
|
33
36
|
},
|
|
34
37
|
"homepage": "https://github.com/PreventiveMeasures/stasis#readme",
|
|
35
38
|
"dependencies": {
|
|
36
|
-
"@exodus/stasis-core": "1.0.0-beta.
|
|
39
|
+
"@exodus/stasis-core": "1.0.0-beta.13"
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
|
|
5
|
+
// Stands in for the project's Babel transformer (`babelTransformerPath`) under ./metro-transformer.js,
|
|
6
|
+
// which swaps it in for each worker transform, to keep each import as WRITTEN in the source. Metro
|
|
7
|
+
// names a dependency by the specifier Babel leaves in its output, so a plugin rewriting it (e.g.
|
|
8
|
+
// babel-plugin-module-resolver mapping `#local/abc` to `./src/abc`) leaves the serializer only the
|
|
9
|
+
// rewrite. This adds one plugin that records every specifier in its `pre` -- Babel runs every
|
|
10
|
+
// plugin's `pre` before any plugin's visitor, so nothing has rewritten one yet -- and reads each
|
|
11
|
+
// again once Babel is done, before Metro's own passes replace them in the AST, unless Babel erased
|
|
12
|
+
// it. The real transformer runs as it would; its output is untouched.
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url)
|
|
15
|
+
|
|
16
|
+
// One per worker transform, run by ./metro-transformer.js: `babelTransformerPath` is the project's
|
|
17
|
+
// (absolute), and `asWritten` maps each specifier Babel left to the ones written there.
|
|
18
|
+
export const transformContext = new AsyncLocalStorage()
|
|
19
|
+
|
|
20
|
+
// The nodes Metro's collectDependencies takes a specifier from: these declarations, and the calls
|
|
21
|
+
// below (`import()`, `require()`, `__prefetchImport()`, `require.<member>()`).
|
|
22
|
+
const DECLARATIONS = new Set(['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'])
|
|
23
|
+
const REQUIRE_MEMBERS = new Set(['context', 'resolveWeak', 'unstable_importMaybeSync'])
|
|
24
|
+
|
|
25
|
+
function isDependencyCall(callee) {
|
|
26
|
+
if (callee.type === 'Import') return true
|
|
27
|
+
if (callee.type === 'Identifier') return callee.name === 'require' || callee.name === '__prefetchImport'
|
|
28
|
+
return callee.type === 'MemberExpression' && !callee.computed &&
|
|
29
|
+
callee.object.type === 'Identifier' && callee.object.name === 'require' &&
|
|
30
|
+
callee.property.type === 'Identifier' && REQUIRE_MEMBERS.has(callee.property.name)
|
|
31
|
+
}
|
|
32
|
+
|
|
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.
|
|
46
|
+
function specifierOf(node) {
|
|
47
|
+
return constantString(node.type === 'CallExpression' ? node.arguments[0] : node.source)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asWrittenPlugin({ types: t }) {
|
|
51
|
+
return {
|
|
52
|
+
name: 'stasis-as-written',
|
|
53
|
+
pre(file) {
|
|
54
|
+
const { slots } = transformContext.getStore()
|
|
55
|
+
let evaluate = false
|
|
56
|
+
t.traverseFast(file.ast, (node) => {
|
|
57
|
+
if (node.type === 'CallExpression' ? !isDependencyCall(node.callee) : !DECLARATIONS.has(node.type)) return
|
|
58
|
+
const written = specifierOf(node)
|
|
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
|
+
},
|
|
71
|
+
})
|
|
72
|
+
},
|
|
73
|
+
visitor: {
|
|
74
|
+
// Babel exits a node only if no plugin removed it on the way in, so a declaration erased for
|
|
75
|
+
// importing types alone (`import type`, `import { type A }`, a TS import used only as a type)
|
|
76
|
+
// never exits: flow-strip-types removes it as it enters it, the TS plugin before the
|
|
77
|
+
// traversal gets there. One lowered to a require exits first: wherever it is listed,
|
|
78
|
+
// @babel/plugin-transform-modules-commonjs takes the declarations away as the program exits.
|
|
79
|
+
'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': {
|
|
80
|
+
exit(path) {
|
|
81
|
+
transformContext.getStore().kept.add(path.node)
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function transform(args) {
|
|
89
|
+
const context = transformContext.getStore()
|
|
90
|
+
assert.ok(context, 'StasisMetro: the stasis Babel transformer runs only under the stasis metro-transformer')
|
|
91
|
+
const slots = []
|
|
92
|
+
const kept = new Set()
|
|
93
|
+
Object.assign(context, { slots, kept })
|
|
94
|
+
// Once Babel is done: each specifier as it left it, from the node that held it as written.
|
|
95
|
+
const collect = (result) => {
|
|
96
|
+
for (const { node, written } of slots) {
|
|
97
|
+
if (DECLARATIONS.has(node.type) && !kept.has(node)) continue
|
|
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)
|
|
100
|
+
if (left === undefined) continue
|
|
101
|
+
if (!context.asWritten.has(left)) context.asWritten.set(left, new Set())
|
|
102
|
+
context.asWritten.get(left).add(written)
|
|
103
|
+
}
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
// Sync or async, as the project's transformer is (the RN one is sync).
|
|
107
|
+
const result = require(context.babelTransformerPath).transform({ ...args, plugins: [...(args.plugins ?? []), asWrittenPlugin] })
|
|
108
|
+
return typeof result?.then === 'function' ? result.then(collect) : collect(result)
|
|
109
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { classifyNativeCapture, isExcludedNativeDir, isNativeArtifact, isPodspec, isSkippedNativeWalkDir, refineNativeCapture } from '@exodus/stasis-core/util'
|
|
4
|
+
|
|
5
|
+
// The native build inputs of a React Native app's packages, shared by the Metro plugin's capture and
|
|
6
|
+
// `stasis bundle --metro` so the two carry the same files. Both read through `fs`:
|
|
7
|
+
// readdir(dir) -> Dirent[] (throws where `dir` isn't one), readFile(file) -> Buffer,
|
|
8
|
+
// stat(path) -> Stats, or null where nothing is there.
|
|
9
|
+
|
|
10
|
+
// True if the package at `root` ships native code: an ios/ or android/ dir, or a podspec under it.
|
|
11
|
+
export function looksNative(root, fs) {
|
|
12
|
+
if (fs.stat(join(root, 'ios')) !== null || fs.stat(join(root, 'android')) !== null) return true
|
|
13
|
+
return hasPodspec(root, fs)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// True if any *.podspec exists under `dir`, pruning the same subtrees the native walk skips.
|
|
17
|
+
function hasPodspec(dir, fs) {
|
|
18
|
+
let entries
|
|
19
|
+
try {
|
|
20
|
+
entries = fs.readdir(dir)
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
for (const ent of entries) {
|
|
25
|
+
if (ent.isSymbolicLink()) continue
|
|
26
|
+
if (ent.isFile()) {
|
|
27
|
+
if (isPodspec(ent.name)) return true
|
|
28
|
+
} else if (ent.isDirectory() && !isSkippedNativeWalkDir(ent.name)) {
|
|
29
|
+
if (hasPodspec(join(dir, ent.name), fs)) return true
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Each native build input in the package at `root`, walked whole, a native build reading sources
|
|
36
|
+
// from anywhere in it (a podspec compiling `cpp/` beside `ios/`): every file but build output and
|
|
37
|
+
// prebuilt binaries (isSkippedNativeWalkDir, isNativeArtifact), an off-platform top-level dir
|
|
38
|
+
// (isExcludedNativeDir), what the shared classifier skips (the JS and TS Metro's graph owns, docs,
|
|
39
|
+
// secrets) and symlinks (a cycle or escape hazard). A file `skip(file)` holds is passed over unread.
|
|
40
|
+
// `resources` is the allowlist the byte-level rules consult. -> yields { file, source, action, format },
|
|
41
|
+
// `action` 'code' (under the source-language `format`) or 'resource'
|
|
42
|
+
export function* nativeInputs(root, fs, { resources = new Set(), skip = () => false } = {}) {
|
|
43
|
+
yield* walk(root, true)
|
|
44
|
+
function* walk(dir, atRoot) {
|
|
45
|
+
let entries
|
|
46
|
+
try {
|
|
47
|
+
entries = fs.readdir(dir)
|
|
48
|
+
} catch {
|
|
49
|
+
return // dir missing / not a directory -- nothing to walk
|
|
50
|
+
}
|
|
51
|
+
for (const ent of entries) {
|
|
52
|
+
if (ent.isSymbolicLink()) continue
|
|
53
|
+
const file = join(dir, ent.name)
|
|
54
|
+
if (ent.isDirectory()) {
|
|
55
|
+
if (!isSkippedNativeWalkDir(ent.name) && !(atRoot && isExcludedNativeDir(ent.name))) yield* walk(file, false)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (!ent.isFile() || isNativeArtifact(ent.name) || skip(file)) continue
|
|
59
|
+
const byName = classifyNativeCapture(ent.name)
|
|
60
|
+
if (byName.action === 'skip') continue
|
|
61
|
+
const source = fs.readFile(file)
|
|
62
|
+
// Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
|
|
63
|
+
const { action, format } = refineNativeCapture(byName, ent.name, source, resources)
|
|
64
|
+
if (action !== 'skip') yield { file, source, action, format }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A package name as npm takes one (`name` or `@scope/name`), which a path can't climb out with.
|
|
70
|
+
const PACKAGE_NAME = /^(?:@[^/\\@.][^/\\]*\/)?[^/\\@.][^/\\]*$/u
|
|
71
|
+
|
|
72
|
+
// The app's packages `react-native config` links, as a native build would, read as data rather than
|
|
73
|
+
// by running the CLI: each name the app's package.json lists under dependencies, peerDependencies or
|
|
74
|
+
// devDependencies (the CLI's list), in the nearest node_modules up from `projectDir` that holds it
|
|
75
|
+
// (as the CLI looks), where it ships native code (looksNative). What a react-native.config.js changes,
|
|
76
|
+
// being code, isn't seen. -> the package dirs, as found
|
|
77
|
+
export function linkedNativePackages(projectDir, fs) {
|
|
78
|
+
let manifest
|
|
79
|
+
try {
|
|
80
|
+
manifest = JSON.parse(fs.readFile(join(projectDir, 'package.json')).toString('utf8'))
|
|
81
|
+
} catch {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
const listed = (field) => (manifest?.[field] && typeof manifest[field] === 'object' ? Object.keys(manifest[field]) : [])
|
|
85
|
+
const names = new Set([...listed('dependencies'), ...listed('peerDependencies'), ...listed('devDependencies')])
|
|
86
|
+
const dirs = []
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
if (!PACKAGE_NAME.test(name)) continue
|
|
89
|
+
for (let dir = projectDir; ; dir = dirname(dir)) {
|
|
90
|
+
const found = join(dir, 'node_modules', name)
|
|
91
|
+
if (fs.stat(found)?.isDirectory()) {
|
|
92
|
+
if (looksNative(found, fs)) dirs.push(found)
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
if (dirname(dir) === dir) break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return dirs
|
|
99
|
+
}
|
package/src/metro-transformer.js
CHANGED
|
@@ -1,41 +1,60 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
|
-
import { resolve as resolvePath } from 'node:path'
|
|
4
|
-
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
5
|
|
|
6
6
|
import { State } from '@exodus/stasis-core/state'
|
|
7
|
+
import { transformContext } from './metro-babel-transformer.js'
|
|
7
8
|
|
|
8
|
-
// Companion to the StasisMetro serializer
|
|
9
|
-
//
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
9
|
+
// Companion to the StasisMetro serializer, which withStasis() wires as Metro's `transformerPath`
|
|
10
|
+
// whenever stasis attests the build (wire it yourself only alongside a hand-wired serializer). It
|
|
11
|
+
// wraps the project's own transformer, and does two things in each worker:
|
|
12
|
+
// - keeps each import as written for the serializer, through the Babel transformer it swaps in
|
|
13
|
+
// (./metro-babel-transformer.js): a dependency Babel rewrote (e.g. through
|
|
14
|
+
// babel-plugin-module-resolver) carries the specifiers written in the source as `stasisAsWritten`;
|
|
15
|
+
// - is the LOAD half of stasis for Metro (under load the serializer only asserts edges). Load only
|
|
16
|
+
// READS an immutable bundle and every worker reads the same bytes (no IPC), so a per-worker
|
|
17
|
+
// transformer is the natural seam: it replaces Metro's on-disk `data` with the bundle's
|
|
18
|
+
// hash-verified bytes (fail-closed; out-of-scope files pass their disk bytes through).
|
|
19
|
+
// KNOWN LIMITATION: does NOT build with sources absent from disk -- Metro reads + hashes each
|
|
20
|
+
// file before this runs, so the guarantee is "build attested bytes, fail closed on disk drift."
|
|
16
21
|
|
|
17
22
|
const require = createRequire(import.meta.url)
|
|
18
23
|
|
|
19
|
-
// The
|
|
20
|
-
//
|
|
21
|
-
|
|
24
|
+
// The project's own transformer, which this one runs: the `transformerPath` withStasis() replaced,
|
|
25
|
+
// handed over in Metro's transformer config, else EXODUS_STASIS_METRO_BASE_TRANSFORMER, else
|
|
26
|
+
// Metro's default. Metro requires a name from its own dir, which only the project's node_modules
|
|
27
|
+
// is sure to reach, so a name resolves from the project root, then from here. A base that is this
|
|
28
|
+
// transformer itself (wired by hand as well) is passed over for Metro's default.
|
|
29
|
+
const bases = new Map()
|
|
30
|
+
function baseOf(config, projectRoot = process.cwd()) {
|
|
31
|
+
const path = config?.stasisBaseTransformerPath ?? (process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker')
|
|
32
|
+
const key = `${projectRoot}\0${path}`
|
|
33
|
+
if (!bases.has(key)) {
|
|
34
|
+
const resolve = (name) => require.resolve(name, { paths: [projectRoot, dirname(fileURLToPath(import.meta.url))] })
|
|
35
|
+
let file = resolve(path)
|
|
36
|
+
if (require(file).transform === transform) file = resolve('metro-transform-worker')
|
|
37
|
+
bases.set(key, { file, transformer: require(file) })
|
|
38
|
+
}
|
|
39
|
+
return bases.get(key)
|
|
40
|
+
}
|
|
22
41
|
|
|
23
|
-
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
return base
|
|
42
|
+
// The config the project's transformer sees: its own, without what withStasis() handed over.
|
|
43
|
+
function baseConfig({ stasisBaseTransformerPath: _, ...config } = {}) {
|
|
44
|
+
return config
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
// Per-worker load State, built once from the usual env + stasis.config.json. Non-load mode -> null
|
|
30
48
|
// -> transparent pass-through, so wiring it permanently is safe. A construction failure is cached
|
|
31
|
-
// and rethrown on every call -- never throw-once-then-silently-pass-through.
|
|
49
|
+
// and rethrown on every call -- never throw-once-then-silently-pass-through. It never writes, so it
|
|
50
|
+
// claims no write path: a capturing worker's own preload State already holds them.
|
|
32
51
|
let stateInited = false
|
|
33
52
|
let loadState = null
|
|
34
53
|
let stateError = null
|
|
35
54
|
function getLoadState() {
|
|
36
55
|
if (!stateInited) {
|
|
37
56
|
try {
|
|
38
|
-
const state = new State(process.cwd())
|
|
57
|
+
const state = new State(process.cwd(), { claim: false })
|
|
39
58
|
loadState = state.config.loadBundle ? state : null
|
|
40
59
|
} catch (err) {
|
|
41
60
|
stateError = err
|
|
@@ -60,7 +79,35 @@ function inScope(state, absolute) {
|
|
|
60
79
|
return state.inNodeModules(pathToFileURL(absolute).toString())
|
|
61
80
|
}
|
|
62
81
|
|
|
63
|
-
|
|
82
|
+
// The Babel transformer swapped in for the project's, by path as metro-transform-worker requires it.
|
|
83
|
+
const BABEL_TRANSFORMER = fileURLToPath(new URL('./metro-babel-transformer.js', import.meta.url))
|
|
84
|
+
|
|
85
|
+
// The project's Babel transformer, absolute. Metro resolves a configured one; its default
|
|
86
|
+
// ('metro-babel-transformer') stays a name, which the base transformer requires from its own dir.
|
|
87
|
+
function resolveBabelTransformer(base, path) {
|
|
88
|
+
return isAbsolute(path) ? path : createRequire(base.file).resolve(path)
|
|
89
|
+
}
|
|
90
|
+
|
|
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.
|
|
97
|
+
function withAsWritten(result, asWritten) {
|
|
98
|
+
if (!Array.isArray(result?.dependencies)) return result
|
|
99
|
+
const dependencies = result.dependencies.map((dep) => {
|
|
100
|
+
const written = asWritten.get(dep.name)
|
|
101
|
+
if (written === undefined) return { ...dep, stasisInserted: true }
|
|
102
|
+
if (written.size === 1 && written.has(dep.name)) return dep
|
|
103
|
+
return { ...dep, stasisAsWritten: [...written] }
|
|
104
|
+
})
|
|
105
|
+
return { ...result, dependencies }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function transform(config, projectRoot, filename, data, options) {
|
|
109
|
+
const base = baseOf(config, projectRoot)
|
|
110
|
+
config = baseConfig(config)
|
|
64
111
|
const state = getLoadState()
|
|
65
112
|
if (state) {
|
|
66
113
|
// `filename` is project-relative in Metro's contract; resolve against projectRoot.
|
|
@@ -72,14 +119,19 @@ export function transform(config, projectRoot, filename, data, options) {
|
|
|
72
119
|
data = Buffer.isBuffer(source) ? source : Buffer.from(source)
|
|
73
120
|
}
|
|
74
121
|
}
|
|
75
|
-
|
|
122
|
+
if (typeof config.babelTransformerPath !== 'string') return base.transformer.transform(config, projectRoot, filename, data, options)
|
|
123
|
+
const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map() }
|
|
124
|
+
const result = await transformContext.run(context, () =>
|
|
125
|
+
base.transformer.transform({ ...config, babelTransformerPath: BABEL_TRANSFORMER }, projectRoot, filename, data, options))
|
|
126
|
+
return withAsWritten(result, context.asWritten)
|
|
76
127
|
}
|
|
77
128
|
|
|
78
|
-
export function getCacheKey(config) {
|
|
129
|
+
export function getCacheKey(config, opts) {
|
|
79
130
|
// Fold in the base transformer's cache key, then add a stasis-load discriminator so load results
|
|
80
131
|
// (which can differ from a disk build even when disk == bundle) don't cross-pollinate the cache.
|
|
81
|
-
// Derive the marker from the resolved load State (
|
|
82
|
-
const
|
|
132
|
+
// Derive the marker from the resolved load State, which transform() goes by.
|
|
133
|
+
const { transformer } = baseOf(config, opts?.projectRoot)
|
|
134
|
+
const baseKey = typeof transformer.getCacheKey === 'function' ? transformer.getCacheKey(baseConfig(config), opts) : ''
|
|
83
135
|
const state = getLoadState()
|
|
84
136
|
const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'
|
|
85
137
|
return `${baseKey}$stasis-metro-transformer:${marker}`
|
package/src/metro.js
CHANGED
|
@@ -3,16 +3,28 @@ 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'
|
|
7
|
-
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
8
8
|
|
|
9
9
|
import { resolvePluginState } from './plugins.js'
|
|
10
|
+
import { looksNative, nativeInputs } from './metro-native.js'
|
|
11
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
10
12
|
import { State } from '@exodus/stasis-core/state'
|
|
11
|
-
import { realReadFileSync
|
|
12
|
-
import { RN_CORE_INCLUDE_FILES, classifyExtension, classifyFormat,
|
|
13
|
+
import { realReadFileSync } from '@exodus/stasis-core/state-util'
|
|
14
|
+
import { RN_CORE_INCLUDE_FILES, classifyExtension, classifyFormat, splitNodeModulesPath } from '@exodus/stasis-core/util'
|
|
13
15
|
|
|
14
16
|
const require = createRequire(import.meta.url)
|
|
15
17
|
|
|
18
|
+
// The companion worker transformer withStasis() wires as Metro's `transformerPath`.
|
|
19
|
+
const TRANSFORMER = fileURLToPath(new URL('./metro-transformer.js', import.meta.url))
|
|
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.
|
|
23
|
+
function diskPath(module) {
|
|
24
|
+
const modPath = module?.path
|
|
25
|
+
return typeof modPath === 'string' && isAbsolute(modPath) && existsSync(modPath) ? modPath : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
// Metro's `graph.entryPoints` is a Set now, an Array before ~0.71; normalize to a Set.
|
|
17
29
|
function entrySet(graph) {
|
|
18
30
|
const ep = graph?.entryPoints
|
|
@@ -28,6 +40,23 @@ const AUTO_INCLUDES = [
|
|
|
28
40
|
'@react-native-community/cli/setup_env.sh',
|
|
29
41
|
]
|
|
30
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
|
+
|
|
31
60
|
// The worker-side toolchain that only Metro's transform workers load, named in both the
|
|
32
61
|
// --child-process assert and the transform-cache warning so the two can't describe different sets.
|
|
33
62
|
const WORKER_TOOLCHAIN = 'babel.config.js, @babel/core, the RN preset + plugins'
|
|
@@ -111,15 +140,16 @@ function metroDefaultSerializer() {
|
|
|
111
140
|
|
|
112
141
|
// Stasis capture plugin for Metro (React Native). Hooks Metro's serializer, which runs ONCE in
|
|
113
142
|
// 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
|
|
115
|
-
//
|
|
116
|
-
//
|
|
143
|
+
// could never be merged. This is the CAPTURE half; the companion ./metro-transformer.js runs in
|
|
144
|
+
// the workers, recording each import as written and serving the LOAD half, and withStasis wires
|
|
145
|
+
// both. Under bundle=load this serializer records nothing: Metro resolved every import itself,
|
|
146
|
+
// so it asserts each edge against the bundle (see #assertEdges).
|
|
117
147
|
// Capture is one-shot (dev-server rebuilds refused, see #run) and REQUIRES --child-process so the
|
|
118
148
|
// worker-side toolchain (babel, RN preset) is attested (enforced in the constructor). withStasis
|
|
119
149
|
// wires the stable customSerializer -- the only surface that receives preModules; serializerHook
|
|
120
150
|
// 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
|
|
122
|
-
// stasis's hands (see withStasis).
|
|
151
|
+
// hand-wired serializer leaves the Metro config -- and therefore its transform cache and
|
|
152
|
+
// transformerPath -- out of stasis's hands (see withStasis).
|
|
123
153
|
export class StasisMetro {
|
|
124
154
|
#seen = new Set()
|
|
125
155
|
#state
|
|
@@ -198,9 +228,12 @@ export class StasisMetro {
|
|
|
198
228
|
|
|
199
229
|
#run(graph, preModules) {
|
|
200
230
|
if (!this.#state) return
|
|
201
|
-
// bundle=load:
|
|
202
|
-
// one-shot guard -- load captures nothing and legitimately re-serializes under a dev server.
|
|
203
|
-
if (this.#state.config.loadBundle)
|
|
231
|
+
// bundle=load: assert, don't record (the worker transformer serves the bundle's bytes). Checked
|
|
232
|
+
// BEFORE the one-shot guard -- load captures nothing and legitimately re-serializes under a dev server.
|
|
233
|
+
if (this.#state.config.loadBundle) {
|
|
234
|
+
this.#assertEdges(graph, preModules)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
204
237
|
// Watch/dev-server capture is unsupported: dedupe is keyed by PATH not content, so a rebuild
|
|
205
238
|
// with changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Refuse
|
|
206
239
|
// the second serialization loudly (the first rebuild is the earliest refusal point Metro allows).
|
|
@@ -236,16 +269,18 @@ export class StasisMetro {
|
|
|
236
269
|
// AFTER the graph so #seen already holds graph modules -- an auto-include that landed in the
|
|
237
270
|
// graph (asyncRequire, when the app uses dynamic import()) is deduped, not re-recorded.
|
|
238
271
|
this.#captureAutoIncludes()
|
|
272
|
+
this.#captureTransformPlugins()
|
|
239
273
|
// AFTER the graph + auto-includes (same dedupe reason): the native pass only adds the native
|
|
240
274
|
// build-input surface (podspec/gradle/sources) that no module graph carries.
|
|
241
275
|
this.#captureNativeModules()
|
|
242
276
|
}
|
|
243
277
|
|
|
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
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
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 })
|
|
249
284
|
}
|
|
250
285
|
|
|
251
286
|
// Attest the AUTO_INCLUDES list. Per entry, skip: unresolvable, already-seen, or resolving
|
|
@@ -270,8 +305,34 @@ export class StasisMetro {
|
|
|
270
305
|
// Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
|
|
271
306
|
// the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
|
|
272
307
|
// would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
|
|
308
|
+
// JS (asyncRequire.js) is code Metro builds, so it takes no format, as the graph's doesn't.
|
|
273
309
|
const format = classifyFormat(modPath, { content: source })
|
|
274
|
-
this.#recordCapture(modPath, source, { format, resource: format === undefined })
|
|
310
|
+
this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
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' })
|
|
275
336
|
}
|
|
276
337
|
}
|
|
277
338
|
|
|
@@ -300,7 +361,7 @@ export class StasisMetro {
|
|
|
300
361
|
} catch {
|
|
301
362
|
continue
|
|
302
363
|
}
|
|
303
|
-
this.#captureNativeTree(root
|
|
364
|
+
this.#captureNativeTree(root)
|
|
304
365
|
}
|
|
305
366
|
// RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
|
|
306
367
|
if (typeof rnPath === 'string' && isAbsolute(rnPath)) {
|
|
@@ -336,73 +397,59 @@ export class StasisMetro {
|
|
|
336
397
|
if (!nm || nm.name === 'react-native') continue
|
|
337
398
|
pkgRoots.add(nm.dir)
|
|
338
399
|
}
|
|
339
|
-
return [...pkgRoots].filter((root) =>
|
|
400
|
+
return [...pkgRoots].filter((root) => looksNative(root, diskHost))
|
|
340
401
|
}
|
|
341
402
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
403
|
+
// Attest the native build inputs of the package at `root` (nativeInputs, shared with `stasis bundle
|
|
404
|
+
// --metro`), but what is already captured as a graph module or auto-include. Real readers: plugin
|
|
405
|
+
// bookkeeping, not re-recorded by --fs.
|
|
406
|
+
#captureNativeTree(root) {
|
|
407
|
+
for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
|
|
408
|
+
this.#seen.add(file)
|
|
409
|
+
this.#recordCapture(file, source, { format, resource: action === 'resource' })
|
|
410
|
+
}
|
|
345
411
|
}
|
|
346
412
|
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
} else if (ent.isDirectory() && !isSkippedNativeWalkDir(ent.name)) {
|
|
360
|
-
if (this.#hasPodspec(join(dir, ent.name))) return true
|
|
361
|
-
}
|
|
413
|
+
// bundle=load: Metro resolved every import itself (the loader and the esbuild/webpack plugins
|
|
414
|
+
// take theirs from the bundle), so before any output each edge must be the one the bundle attests
|
|
415
|
+
// -- the edges a capture records, from the same modules.
|
|
416
|
+
// A per-platform edge (a `stasis bundle --metro --platforms=...` bundle added to) is checked for the
|
|
417
|
+
// platform Metro builds.
|
|
418
|
+
#assertEdges(graph, preModules) {
|
|
419
|
+
const platform = graph.transformOptions?.platform ?? undefined
|
|
420
|
+
for (const module of [...(preModules ?? []), ...graph.dependencies.values()]) {
|
|
421
|
+
const modPath = diskPath(module)
|
|
422
|
+
if (modPath === undefined) continue
|
|
423
|
+
const url = pathToFileURL(modPath).toString()
|
|
424
|
+
for (const [specifier, target] of this.#edges(module)) this.#state.assertImport(url, specifier, target, { platform })
|
|
362
425
|
}
|
|
363
|
-
return false
|
|
364
426
|
}
|
|
365
427
|
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
for (const
|
|
376
|
-
if (
|
|
377
|
-
const
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (
|
|
382
|
-
continue
|
|
428
|
+
// A module's outgoing edges, keyed as written, to `dep.absolutePath`. Metro names a dependency
|
|
429
|
+
// (`dep.data.name`) by the specifier Babel left; where a plugin rewrote it, the stasis transformer
|
|
430
|
+
// (./metro-transformer.js) carries the ones written in the source. Code and resource targets alike,
|
|
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.
|
|
435
|
+
#edges(module) {
|
|
436
|
+
const edges = []
|
|
437
|
+
for (const dep of module.dependencies?.values?.() ?? []) {
|
|
438
|
+
if (dep?.data?.stasisInserted === true) continue
|
|
439
|
+
const target = dep?.absolutePath
|
|
440
|
+
if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
|
|
441
|
+
if (classifyExtension(target, this.#resources) === 'unknown') continue
|
|
442
|
+
for (const specifier of dep.data?.stasisAsWritten ?? [dep.data?.name]) {
|
|
443
|
+
if (typeof specifier === 'string') edges.push([specifier, pathToFileURL(target).toString()])
|
|
383
444
|
}
|
|
384
|
-
if (!ent.isFile()) continue // sockets/FIFOs/etc. -- no attestable bytes
|
|
385
|
-
// Prebuilt binary artifacts are build output, non-deterministic -- never captured.
|
|
386
|
-
if (isNativeArtifact(ent.name)) continue
|
|
387
|
-
if (this.#seen.has(full)) continue // already captured as a graph module or auto-include
|
|
388
|
-
const byName = classifyNativeCapture(ent.name)
|
|
389
|
-
// Node-runnable JS/TS is captured via Metro's graph, not here (action==='skip').
|
|
390
|
-
if (byName.action === 'skip') continue
|
|
391
|
-
const source = realReadFileSync(full)
|
|
392
|
-
// Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
|
|
393
|
-
const { action, format } = refineNativeCapture(byName, ent.name, source, this.#resources)
|
|
394
|
-
if (action === 'skip') continue
|
|
395
|
-
this.#seen.add(full)
|
|
396
|
-
this.#recordCapture(full, source, { format, resource: action === 'resource' })
|
|
397
445
|
}
|
|
446
|
+
return edges
|
|
398
447
|
}
|
|
399
448
|
|
|
400
449
|
#captureModules(modules, entries) {
|
|
401
450
|
for (const module of modules) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// Skip synthetic/virtual modules (require.context's ?ctx=, __prelude__): no on-disk bytes to hash.
|
|
405
|
-
if (typeof modPath !== 'string' || !isAbsolute(modPath) || !existsSync(modPath)) continue
|
|
451
|
+
const modPath = diskPath(module)
|
|
452
|
+
if (modPath === undefined) continue
|
|
406
453
|
|
|
407
454
|
const kind = classifyExtension(modPath, this.#resources)
|
|
408
455
|
if (kind === 'unknown') {
|
|
@@ -412,17 +459,7 @@ export class StasisMetro {
|
|
|
412
459
|
)
|
|
413
460
|
}
|
|
414
461
|
const url = pathToFileURL(modPath).toString()
|
|
415
|
-
|
|
416
|
-
// Outgoing edges: as-written specifier is `dep.data.name`, target `dep.absolutePath`.
|
|
417
|
-
// Only code targets get an edge (resource imports are captured as files, no edge).
|
|
418
|
-
for (const dep of module.dependencies?.values?.() ?? []) {
|
|
419
|
-
const target = dep?.absolutePath
|
|
420
|
-
const specifier = dep?.data?.name
|
|
421
|
-
if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
|
|
422
|
-
if (typeof specifier !== 'string') continue
|
|
423
|
-
if (classifyExtension(target, this.#resources) !== 'code') continue
|
|
424
|
-
this.#state.addImport(url, specifier, pathToFileURL(target).toString())
|
|
425
|
-
}
|
|
462
|
+
for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
|
|
426
463
|
|
|
427
464
|
if (this.#seen.has(modPath)) continue
|
|
428
465
|
this.#seen.add(modPath)
|
|
@@ -434,14 +471,17 @@ export class StasisMetro {
|
|
|
434
471
|
if (kind === 'code') {
|
|
435
472
|
assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
|
|
436
473
|
}
|
|
437
|
-
|
|
474
|
+
// No format for a code file: Metro transforms it from source, so Node's module/commonjs isn't
|
|
475
|
+
// what runs (a resource still takes its byte-derived one).
|
|
476
|
+
this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', inferFormat: false, reason: 'metro' })
|
|
438
477
|
}
|
|
439
478
|
}
|
|
440
479
|
}
|
|
441
480
|
|
|
442
481
|
// Idiomatic Metro-config wrapper: returns a new config with `serializer.customSerializer` wired
|
|
443
482
|
// so stasis captures the graph + preModules while your existing serializer (or Metro's default)
|
|
444
|
-
// still produces the bundle,
|
|
483
|
+
// still produces the bundle, with Metro's transform cache dropped so the workers really run, and
|
|
484
|
+
// with Metro's transformer run through stasis's (./metro-transformer.js).
|
|
445
485
|
// Pure -- returns a new object, doesn't mutate `config`.
|
|
446
486
|
export function withStasis(config = {}, options = {}) {
|
|
447
487
|
const stasis = new StasisMetro(options, { ownsConfig: true })
|
|
@@ -474,9 +514,18 @@ export function withStasis(config = {}, options = {}) {
|
|
|
474
514
|
'transform skips the worker, leaving the toolchain it loads unattested'
|
|
475
515
|
)
|
|
476
516
|
}
|
|
517
|
+
// The workers run the project's transformer through stasis's, which records each import as
|
|
518
|
+
// written, before Babel rewrites it, and serves bundle=load: handed over in `transformer`, which
|
|
519
|
+
// Metro passes to each worker's transformer as is. In every active mode, load included, so the
|
|
520
|
+
// modules a worker loads are the same when recorded and when replayed.
|
|
521
|
+
const transformer = stasis.needsTransforms && {
|
|
522
|
+
transformerPath: TRANSFORMER,
|
|
523
|
+
transformer: { ...config.transformer, stasisBaseTransformerPath: config.transformerPath },
|
|
524
|
+
}
|
|
477
525
|
return {
|
|
478
526
|
...config,
|
|
479
527
|
...(dropCache && { cacheStores: [] }),
|
|
528
|
+
...transformer,
|
|
480
529
|
serializer: {
|
|
481
530
|
...config.serializer,
|
|
482
531
|
customSerializer: stasis.customSerializer(existing),
|