@exodus/stasis-plugins 1.0.0-beta.11 → 1.0.0-beta.12
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 +86 -0
- package/src/metro-native.js +99 -0
- package/src/metro-transformer.js +72 -24
- package/src/metro.js +86 -84
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.12",
|
|
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.12"
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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 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.
|
|
35
|
+
function specifierOf(node) {
|
|
36
|
+
const literal = node.type === 'CallExpression' ? node.arguments[0] : node.source
|
|
37
|
+
return literal?.type === 'StringLiteral' ? literal.value : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function asWrittenPlugin({ types: t }) {
|
|
41
|
+
return {
|
|
42
|
+
name: 'stasis-as-written',
|
|
43
|
+
pre(file) {
|
|
44
|
+
const { slots } = transformContext.getStore()
|
|
45
|
+
t.traverseFast(file.ast, (node) => {
|
|
46
|
+
if (node.type === 'CallExpression' ? !isDependencyCall(node.callee) : !DECLARATIONS.has(node.type)) return
|
|
47
|
+
const written = specifierOf(node)
|
|
48
|
+
if (written !== undefined) slots.push({ node, written })
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
visitor: {
|
|
52
|
+
// Babel exits a node only if no plugin removed it on the way in, so a declaration erased for
|
|
53
|
+
// importing types alone (`import type`, `import { type A }`, a TS import used only as a type)
|
|
54
|
+
// never exits: flow-strip-types removes it as it enters it, the TS plugin before the
|
|
55
|
+
// traversal gets there. One lowered to a require exits first: wherever it is listed,
|
|
56
|
+
// @babel/plugin-transform-modules-commonjs takes the declarations away as the program exits.
|
|
57
|
+
'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': {
|
|
58
|
+
exit(path) {
|
|
59
|
+
transformContext.getStore().kept.add(path.node)
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function transform(args) {
|
|
67
|
+
const context = transformContext.getStore()
|
|
68
|
+
assert.ok(context, 'StasisMetro: the stasis Babel transformer runs only under the stasis metro-transformer')
|
|
69
|
+
const slots = []
|
|
70
|
+
const kept = new Set()
|
|
71
|
+
Object.assign(context, { slots, kept })
|
|
72
|
+
// Once Babel is done: each specifier as it left it, from the node that held it as written.
|
|
73
|
+
const collect = (result) => {
|
|
74
|
+
for (const { node, written } of slots) {
|
|
75
|
+
if (DECLARATIONS.has(node.type) && !kept.has(node)) continue
|
|
76
|
+
const left = specifierOf(node)
|
|
77
|
+
if (left === undefined) continue
|
|
78
|
+
if (!context.asWritten.has(left)) context.asWritten.set(left, new Set())
|
|
79
|
+
context.asWritten.get(left).add(written)
|
|
80
|
+
}
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
// Sync or async, as the project's transformer is (the RN one is sync).
|
|
84
|
+
const result = require(context.babelTransformerPath).transform({ ...args, plugins: [...(args.plugins ?? []), asWrittenPlugin] })
|
|
85
|
+
return typeof result?.then === 'function' ? result.then(collect) : collect(result)
|
|
86
|
+
}
|
|
@@ -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,31 @@ 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 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.
|
|
94
|
+
function withAsWritten(result, asWritten) {
|
|
95
|
+
if (asWritten.size === 0 || !Array.isArray(result?.dependencies)) return result
|
|
96
|
+
const dependencies = result.dependencies.map((dep) => {
|
|
97
|
+
const written = asWritten.get(dep.name)
|
|
98
|
+
if (written === undefined || (written.size === 1 && written.has(dep.name))) return dep
|
|
99
|
+
return { ...dep, stasisAsWritten: [...written] }
|
|
100
|
+
})
|
|
101
|
+
return { ...result, dependencies }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function transform(config, projectRoot, filename, data, options) {
|
|
105
|
+
const base = baseOf(config, projectRoot)
|
|
106
|
+
config = baseConfig(config)
|
|
64
107
|
const state = getLoadState()
|
|
65
108
|
if (state) {
|
|
66
109
|
// `filename` is project-relative in Metro's contract; resolve against projectRoot.
|
|
@@ -72,14 +115,19 @@ export function transform(config, projectRoot, filename, data, options) {
|
|
|
72
115
|
data = Buffer.isBuffer(source) ? source : Buffer.from(source)
|
|
73
116
|
}
|
|
74
117
|
}
|
|
75
|
-
|
|
118
|
+
if (typeof config.babelTransformerPath !== 'string') return base.transformer.transform(config, projectRoot, filename, data, options)
|
|
119
|
+
const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map() }
|
|
120
|
+
const result = await transformContext.run(context, () =>
|
|
121
|
+
base.transformer.transform({ ...config, babelTransformerPath: BABEL_TRANSFORMER }, projectRoot, filename, data, options))
|
|
122
|
+
return withAsWritten(result, context.asWritten)
|
|
76
123
|
}
|
|
77
124
|
|
|
78
|
-
export function getCacheKey(config) {
|
|
125
|
+
export function getCacheKey(config, opts) {
|
|
79
126
|
// Fold in the base transformer's cache key, then add a stasis-load discriminator so load results
|
|
80
127
|
// (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
|
|
128
|
+
// Derive the marker from the resolved load State, which transform() goes by.
|
|
129
|
+
const { transformer } = baseOf(config, opts?.projectRoot)
|
|
130
|
+
const baseKey = typeof transformer.getCacheKey === 'function' ? transformer.getCacheKey(baseConfig(config), opts) : ''
|
|
83
131
|
const state = getLoadState()
|
|
84
132
|
const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'
|
|
85
133
|
return `${baseKey}$stasis-metro-transformer:${marker}`
|
package/src/metro.js
CHANGED
|
@@ -4,15 +4,27 @@ import { spawnSync } from 'node:child_process'
|
|
|
4
4
|
import { existsSync } from 'node:fs'
|
|
5
5
|
import { createRequire } from 'node:module'
|
|
6
6
|
import { isAbsolute, join } from 'node:path'
|
|
7
|
-
import { pathToFileURL } from 'node:url'
|
|
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
|
|
@@ -111,15 +123,16 @@ function metroDefaultSerializer() {
|
|
|
111
123
|
|
|
112
124
|
// Stasis capture plugin for Metro (React Native). Hooks Metro's serializer, which runs ONCE in
|
|
113
125
|
// 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
|
-
//
|
|
126
|
+
// could never be merged. This is the CAPTURE half; the companion ./metro-transformer.js runs in
|
|
127
|
+
// the workers, recording each import as written and serving the LOAD half, and withStasis wires
|
|
128
|
+
// both. Under bundle=load this serializer records nothing: Metro resolved every import itself,
|
|
129
|
+
// so it asserts each edge against the bundle (see #assertEdges).
|
|
117
130
|
// Capture is one-shot (dev-server rebuilds refused, see #run) and REQUIRES --child-process so the
|
|
118
131
|
// worker-side toolchain (babel, RN preset) is attested (enforced in the constructor). withStasis
|
|
119
132
|
// wires the stable customSerializer -- the only surface that receives preModules; serializerHook
|
|
120
133
|
// 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).
|
|
134
|
+
// hand-wired serializer leaves the Metro config -- and therefore its transform cache and
|
|
135
|
+
// transformerPath -- out of stasis's hands (see withStasis).
|
|
123
136
|
export class StasisMetro {
|
|
124
137
|
#seen = new Set()
|
|
125
138
|
#state
|
|
@@ -198,9 +211,12 @@ export class StasisMetro {
|
|
|
198
211
|
|
|
199
212
|
#run(graph, preModules) {
|
|
200
213
|
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)
|
|
214
|
+
// bundle=load: assert, don't record (the worker transformer serves the bundle's bytes). Checked
|
|
215
|
+
// BEFORE the one-shot guard -- load captures nothing and legitimately re-serializes under a dev server.
|
|
216
|
+
if (this.#state.config.loadBundle) {
|
|
217
|
+
this.#assertEdges(graph, preModules)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
204
220
|
// Watch/dev-server capture is unsupported: dedupe is keyed by PATH not content, so a rebuild
|
|
205
221
|
// with changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Refuse
|
|
206
222
|
// the second serialization loudly (the first rebuild is the earliest refusal point Metro allows).
|
|
@@ -242,10 +258,11 @@ export class StasisMetro {
|
|
|
242
258
|
}
|
|
243
259
|
|
|
244
260
|
// 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
|
-
// addFile already asserts UTF-8 for a non-resource (code) source, so
|
|
247
|
-
|
|
248
|
-
|
|
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' })
|
|
249
266
|
}
|
|
250
267
|
|
|
251
268
|
// Attest the AUTO_INCLUDES list. Per entry, skip: unresolvable, already-seen, or resolving
|
|
@@ -270,8 +287,9 @@ export class StasisMetro {
|
|
|
270
287
|
// Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
|
|
271
288
|
// the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
|
|
272
289
|
// would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
|
|
290
|
+
// JS (asyncRequire.js) is code Metro builds, so it takes no format, as the graph's doesn't.
|
|
273
291
|
const format = classifyFormat(modPath, { content: source })
|
|
274
|
-
this.#recordCapture(modPath, source, { format, resource: format === undefined })
|
|
292
|
+
this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false })
|
|
275
293
|
}
|
|
276
294
|
}
|
|
277
295
|
|
|
@@ -300,7 +318,7 @@ export class StasisMetro {
|
|
|
300
318
|
} catch {
|
|
301
319
|
continue
|
|
302
320
|
}
|
|
303
|
-
this.#captureNativeTree(root
|
|
321
|
+
this.#captureNativeTree(root)
|
|
304
322
|
}
|
|
305
323
|
// RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
|
|
306
324
|
if (typeof rnPath === 'string' && isAbsolute(rnPath)) {
|
|
@@ -336,73 +354,55 @@ export class StasisMetro {
|
|
|
336
354
|
if (!nm || nm.name === 'react-native') continue
|
|
337
355
|
pkgRoots.add(nm.dir)
|
|
338
356
|
}
|
|
339
|
-
return [...pkgRoots].filter((root) =>
|
|
357
|
+
return [...pkgRoots].filter((root) => looksNative(root, diskHost))
|
|
340
358
|
}
|
|
341
359
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
360
|
+
// Attest the native build inputs of the package at `root` (nativeInputs, shared with `stasis bundle
|
|
361
|
+
// --metro`), but what is already captured as a graph module or auto-include. Real readers: plugin
|
|
362
|
+
// bookkeeping, not re-recorded by --fs.
|
|
363
|
+
#captureNativeTree(root) {
|
|
364
|
+
for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
|
|
365
|
+
this.#seen.add(file)
|
|
366
|
+
this.#recordCapture(file, source, { format, resource: action === 'resource' })
|
|
367
|
+
}
|
|
345
368
|
}
|
|
346
369
|
|
|
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
|
-
}
|
|
370
|
+
// bundle=load: Metro resolved every import itself (the loader and the esbuild/webpack plugins
|
|
371
|
+
// take theirs from the bundle), so before any output each edge must be the one the bundle attests
|
|
372
|
+
// -- the edges a capture records, from the same modules.
|
|
373
|
+
// A per-platform edge (a `stasis bundle --metro --platforms=...` bundle added to) is checked for the
|
|
374
|
+
// platform Metro builds.
|
|
375
|
+
#assertEdges(graph, preModules) {
|
|
376
|
+
const platform = graph.transformOptions?.platform ?? undefined
|
|
377
|
+
for (const module of [...(preModules ?? []), ...graph.dependencies.values()]) {
|
|
378
|
+
const modPath = diskPath(module)
|
|
379
|
+
if (modPath === undefined) continue
|
|
380
|
+
const url = pathToFileURL(modPath).toString()
|
|
381
|
+
for (const [specifier, target] of this.#edges(module)) this.#state.assertImport(url, specifier, target, { platform })
|
|
362
382
|
}
|
|
363
|
-
return false
|
|
364
383
|
}
|
|
365
384
|
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (ent.isDirectory()) {
|
|
379
|
-
// Skip build-output subtrees, Apple binary bundles + their loose per-arch slice dirs, and an
|
|
380
|
-
// off-platform toplevel dir.
|
|
381
|
-
if (!isSkippedNativeWalkDir(ent.name) && !(atRoot && isExcludedNativeDir(ent.name))) this.#captureNativeTree(full)
|
|
382
|
-
continue
|
|
385
|
+
// A module's outgoing edges, keyed as written, to `dep.absolutePath`. Metro names a dependency
|
|
386
|
+
// (`dep.data.name`) by the specifier Babel left; where a plugin rewrote it, the stasis transformer
|
|
387
|
+
// (./metro-transformer.js) carries the ones written in the source. Code and resource targets alike,
|
|
388
|
+
// as `stasis bundle --metro` and the esbuild plugin record them: an asset is resolved as code is.
|
|
389
|
+
#edges(module) {
|
|
390
|
+
const edges = []
|
|
391
|
+
for (const dep of module.dependencies?.values?.() ?? []) {
|
|
392
|
+
const target = dep?.absolutePath
|
|
393
|
+
if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
|
|
394
|
+
if (classifyExtension(target, this.#resources) === 'unknown') continue
|
|
395
|
+
for (const specifier of dep.data?.stasisAsWritten ?? [dep.data?.name]) {
|
|
396
|
+
if (typeof specifier === 'string') edges.push([specifier, pathToFileURL(target).toString()])
|
|
383
397
|
}
|
|
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
398
|
}
|
|
399
|
+
return edges
|
|
398
400
|
}
|
|
399
401
|
|
|
400
402
|
#captureModules(modules, entries) {
|
|
401
403
|
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
|
|
404
|
+
const modPath = diskPath(module)
|
|
405
|
+
if (modPath === undefined) continue
|
|
406
406
|
|
|
407
407
|
const kind = classifyExtension(modPath, this.#resources)
|
|
408
408
|
if (kind === 'unknown') {
|
|
@@ -412,17 +412,7 @@ export class StasisMetro {
|
|
|
412
412
|
)
|
|
413
413
|
}
|
|
414
414
|
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
|
-
}
|
|
415
|
+
for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
|
|
426
416
|
|
|
427
417
|
if (this.#seen.has(modPath)) continue
|
|
428
418
|
this.#seen.add(modPath)
|
|
@@ -434,14 +424,17 @@ export class StasisMetro {
|
|
|
434
424
|
if (kind === 'code') {
|
|
435
425
|
assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
|
|
436
426
|
}
|
|
437
|
-
|
|
427
|
+
// No format for a code file: Metro transforms it from source, so Node's module/commonjs isn't
|
|
428
|
+
// what runs (a resource still takes its byte-derived one).
|
|
429
|
+
this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', inferFormat: false, reason: 'metro' })
|
|
438
430
|
}
|
|
439
431
|
}
|
|
440
432
|
}
|
|
441
433
|
|
|
442
434
|
// Idiomatic Metro-config wrapper: returns a new config with `serializer.customSerializer` wired
|
|
443
435
|
// so stasis captures the graph + preModules while your existing serializer (or Metro's default)
|
|
444
|
-
// still produces the bundle,
|
|
436
|
+
// still produces the bundle, with Metro's transform cache dropped so the workers really run, and
|
|
437
|
+
// with Metro's transformer run through stasis's (./metro-transformer.js).
|
|
445
438
|
// Pure -- returns a new object, doesn't mutate `config`.
|
|
446
439
|
export function withStasis(config = {}, options = {}) {
|
|
447
440
|
const stasis = new StasisMetro(options, { ownsConfig: true })
|
|
@@ -474,9 +467,18 @@ export function withStasis(config = {}, options = {}) {
|
|
|
474
467
|
'transform skips the worker, leaving the toolchain it loads unattested'
|
|
475
468
|
)
|
|
476
469
|
}
|
|
470
|
+
// The workers run the project's transformer through stasis's, which records each import as
|
|
471
|
+
// written, before Babel rewrites it, and serves bundle=load: handed over in `transformer`, which
|
|
472
|
+
// Metro passes to each worker's transformer as is. In every active mode, load included, so the
|
|
473
|
+
// modules a worker loads are the same when recorded and when replayed.
|
|
474
|
+
const transformer = stasis.needsTransforms && {
|
|
475
|
+
transformerPath: TRANSFORMER,
|
|
476
|
+
transformer: { ...config.transformer, stasisBaseTransformerPath: config.transformerPath },
|
|
477
|
+
}
|
|
477
478
|
return {
|
|
478
479
|
...config,
|
|
479
480
|
...(dropCache && { cacheStores: [] }),
|
|
481
|
+
...transformer,
|
|
480
482
|
serializer: {
|
|
481
483
|
...config.serializer,
|
|
482
484
|
customSerializer: stasis.customSerializer(existing),
|