@exodus/stasis-plugins 1.0.0-beta.9 → 1.0.0-rc.0
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/esbuild.js +83 -170
- package/src/metro-babel-transformer.js +129 -0
- package/src/metro-native.js +201 -0
- package/src/metro-transformer.js +68 -55
- package/src/metro.js +258 -282
- package/src/plugins.js +102 -111
- package/src/rollup.js +64 -188
- package/src/webpack.js +61 -181
package/src/metro.js
CHANGED
|
@@ -1,39 +1,51 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import {
|
|
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
|
-
import { isAbsolute, join } from 'node:path'
|
|
7
|
-
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
8
8
|
|
|
9
|
-
import { resolvePluginState } from './plugins.js'
|
|
9
|
+
import { assertCodeUtf8, classifyOrThrow, resolvePluginState } from './plugins.js'
|
|
10
|
+
import { AUTO_INCLUDES, FILE_STAT, RN_CORE_INCLUDE_FILES, bundleDirHost, looksNative, metroAssetFiles, nativeInputs } from './metro-native.js'
|
|
11
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
10
12
|
import { State } from '@exodus/stasis-core/state'
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
+
// realReadFileSync throughout: the plugin's own reads, which --fs must not record as the program's.
|
|
14
|
+
import { inScope, realReadFileSync } from '@exodus/stasis-core/state-util'
|
|
15
|
+
import { classifyExtension, classifyFormat, isPathWithin, nodeExtFormat, splitNodeModulesPath } from '@exodus/stasis-core/util'
|
|
13
16
|
|
|
14
17
|
const require = createRequire(import.meta.url)
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return
|
|
19
|
+
const TRANSFORMER = fileURLToPath(new URL('./metro-transformer.js', import.meta.url))
|
|
20
|
+
|
|
21
|
+
// A graph module's absolute path, or undefined for a virtual one (require.context's ?ctx=, __prelude__).
|
|
22
|
+
function diskPath(module) {
|
|
23
|
+
const modPath = module?.path
|
|
24
|
+
return typeof modPath === 'string' && isAbsolute(modPath) && existsSync(modPath) ? modPath : undefined
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 : [])
|
|
29
|
+
|
|
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).
|
|
32
|
+
function metroTransformPluginFiles(projectDir) {
|
|
33
|
+
for (const chain of [['metro-transform-plugins'], ['metro', 'metro-transform-worker', 'metro-transform-plugins']]) {
|
|
34
|
+
let file
|
|
35
|
+
try {
|
|
36
|
+
for (const name of chain) file = require.resolve(name, { paths: [file === undefined ? projectDir : dirname(file)] })
|
|
37
|
+
} catch {
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
const { getTransformPluginCacheKeyFiles } = require(file)
|
|
41
|
+
return typeof getTransformPluginCacheKeyFiles === 'function' ? getTransformPluginCacheKeyFiles() : []
|
|
42
|
+
}
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
30
45
|
|
|
31
|
-
// The worker-side toolchain that only Metro's transform workers load, named in both the
|
|
32
|
-
// --child-process assert and the transform-cache warning so the two can't describe different sets.
|
|
33
46
|
const WORKER_TOOLCHAIN = 'babel.config.js, @babel/core, the RN preset + plugins'
|
|
34
47
|
|
|
35
|
-
//
|
|
36
|
-
// 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.
|
|
37
49
|
function loadReactNativeConfig(projectDir) {
|
|
38
50
|
let cliPath
|
|
39
51
|
try {
|
|
@@ -41,27 +53,14 @@ function loadReactNativeConfig(projectDir) {
|
|
|
41
53
|
} catch {
|
|
42
54
|
return null
|
|
43
55
|
}
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
if (key.startsWith('EXODUS_STASIS_')) delete env[key]
|
|
49
|
-
}
|
|
50
|
-
delete env.NODE_OPTIONS
|
|
51
|
-
const res = spawnSync(process.execPath, [cliPath, 'config'], {
|
|
52
|
-
cwd: projectDir,
|
|
53
|
-
encoding: 'utf8',
|
|
54
|
-
maxBuffer: 128 * 1024 * 1024, // a large monorepo's config JSON can be many MB
|
|
55
|
-
env,
|
|
56
|
-
})
|
|
57
|
-
if (res.error) {
|
|
58
|
-
throw new Error(`StasisMetro: failed to run 'react-native config': ${res.error.message}`, { cause: res.error })
|
|
59
|
-
}
|
|
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 })
|
|
60
60
|
if (res.status !== 0) {
|
|
61
61
|
const detail = (res.stderr || '').trim()
|
|
62
62
|
throw new Error(
|
|
63
|
-
`StasisMetro: 'react-native config' exited ${res.status}${res.signal ? ` on signal ${res.signal}` : ''}`
|
|
64
|
-
`${detail ? `: ${detail}` : ''}`
|
|
63
|
+
`StasisMetro: 'react-native config' exited ${res.status}${res.signal ? ` on signal ${res.signal}` : ''}${detail ? `: ${detail}` : ''}`
|
|
65
64
|
)
|
|
66
65
|
}
|
|
67
66
|
try {
|
|
@@ -71,28 +70,27 @@ function loadReactNativeConfig(projectDir) {
|
|
|
71
70
|
}
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
//
|
|
73
|
+
// An all-null dep is JS-only, already in the graph.
|
|
75
74
|
function isNativeDependency(dep) {
|
|
76
75
|
const platforms = dep?.platforms
|
|
77
|
-
|
|
78
|
-
return Object.values(platforms).some((p) => p != null)
|
|
76
|
+
return typeof platforms === 'object' && platforms !== null && Object.values(platforms).some((p) => p != null)
|
|
79
77
|
}
|
|
80
78
|
|
|
81
|
-
// Metro
|
|
82
|
-
// baseJSBundle + bundleToString. Path moved: metro/src/* through ~0.82, metro/private/* from
|
|
83
|
-
// 0.81.4 on (the only option from 0.83), so the two-tier require below spans the range.
|
|
79
|
+
// Metro exports no default serializer: its internal one is under metro/src through ~0.82, metro/private from 0.81.4.
|
|
84
80
|
let metroDefault
|
|
85
81
|
function metroDefaultSerializer() {
|
|
86
82
|
if (metroDefault) return metroDefault
|
|
87
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
|
+
})
|
|
88
88
|
let baseJSBundle, bundleToString
|
|
89
89
|
try {
|
|
90
|
-
baseJSBundle =
|
|
91
|
-
bundleToString = require(`${M}/src/lib/bundleToString`)
|
|
90
|
+
[baseJSBundle, bundleToString] = load('src')
|
|
92
91
|
} catch {
|
|
93
92
|
try {
|
|
94
|
-
baseJSBundle =
|
|
95
|
-
bundleToString = require(`${M}/private/lib/bundleToString`)
|
|
93
|
+
[baseJSBundle, bundleToString] = load('private')
|
|
96
94
|
} catch (cause) {
|
|
97
95
|
throw new Error(
|
|
98
96
|
"StasisMetro: couldn't load Metro's default serializer (tried metro/src and " +
|
|
@@ -101,48 +99,29 @@ function metroDefaultSerializer() {
|
|
|
101
99
|
)
|
|
102
100
|
}
|
|
103
101
|
}
|
|
104
|
-
|
|
105
|
-
baseJSBundle = baseJSBundle.default ?? baseJSBundle
|
|
106
|
-
bundleToString = bundleToString.default ?? bundleToString
|
|
107
|
-
metroDefault = (entryPoint, preModules, graph, options) =>
|
|
108
|
-
bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
|
|
102
|
+
metroDefault = (entryPoint, preModules, graph, options) => bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
|
|
109
103
|
return metroDefault
|
|
110
104
|
}
|
|
111
105
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// ./metro-transformer.js. Wire both permanently (withStasis + transformerPath); the mode picks
|
|
116
|
-
// the active one, and under bundle=load this serializer is a transparent pass-through.
|
|
117
|
-
// Capture is one-shot (dev-server rebuilds refused, see #run) and REQUIRES --child-process so the
|
|
118
|
-
// worker-side toolchain (babel, RN preset) is attested (enforced in the constructor). withStasis
|
|
119
|
-
// wires the stable customSerializer -- the only surface that receives preModules; serializerHook
|
|
120
|
-
// is a lower-coverage fallback that never sees them. Prefer withStasis for a second reason too: a
|
|
121
|
-
// hand-wired serializer leaves the Metro config -- and therefore its transform cache -- out of
|
|
122
|
-
// stasis's hands (see withStasis).
|
|
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.
|
|
123
109
|
export class StasisMetro {
|
|
124
110
|
#seen = new Set()
|
|
111
|
+
#realRoot
|
|
112
|
+
#bundleDirs
|
|
125
113
|
#state
|
|
126
114
|
#resources
|
|
127
|
-
|
|
128
|
-
// 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.
|
|
129
116
|
#projectDir = process.cwd()
|
|
130
|
-
|
|
131
|
-
// Whether this instance already captured; the dev-server rebuild guard in #run trips on the second.
|
|
132
117
|
#ran = false
|
|
133
|
-
|
|
134
|
-
// Set by withStasis, which returns the Metro config and can therefore drop its transform cache;
|
|
135
|
-
// a hand-wired customSerializer/serializerHook can't, so #run warns instead. Internal wiring, in
|
|
136
|
-
// the second options bag like StasisEsbuild's -- not a user-facing option.
|
|
137
118
|
#ownsConfig
|
|
138
119
|
|
|
139
120
|
constructor(options = {}, { ownsConfig = false } = {}) {
|
|
140
121
|
const { state } = resolvePluginState('StasisMetro', options, process.cwd())
|
|
141
|
-
this.#state = state
|
|
122
|
+
this.#state = state
|
|
142
123
|
this.#ownsConfig = ownsConfig
|
|
143
|
-
//
|
|
144
|
-
// observed only in workers. Gate on writeLockfile||writeBundle so it's demanded only when
|
|
145
|
-
// forwarding does something (frozen/load verify per-process and need no channel).
|
|
124
|
+
// The worker toolchain is only ever loaded in workers, so a writing capture needs --child-process.
|
|
146
125
|
if (state && (state.config.writeLockfile || state.config.writeBundle)) {
|
|
147
126
|
assert.ok(
|
|
148
127
|
state.config.childProcess,
|
|
@@ -151,66 +130,52 @@ export class StasisMetro {
|
|
|
151
130
|
'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
|
|
152
131
|
'or "childProcess": true in stasis.config.json.'
|
|
153
132
|
)
|
|
154
|
-
// Opt
|
|
155
|
-
//
|
|
156
|
-
// PRELOAD's mode where one exists (the shard channel follows it, not a Rule-6 sidecar), and
|
|
157
|
-
// use `||=` not `=` so an explicit ambient opt-out ('0'/'false') is honored.
|
|
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.
|
|
158
135
|
const channel = State.preload ?? state
|
|
159
136
|
if (channel.config.writeLockfile || channel.config.writeBundle) {
|
|
160
137
|
process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1'
|
|
161
138
|
}
|
|
162
139
|
}
|
|
163
|
-
// Cache the resolved resources Set for the per-file classify hot path.
|
|
164
140
|
this.#resources = state?.config.resources ?? new Set()
|
|
165
141
|
}
|
|
166
142
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// transform skips the work. False only when inert or attesting nothing ('none'/'ignore', what
|
|
170
|
-
// useLockfile/bundle exclude). Read by withStasis to decide whether to drop the transform cache.
|
|
143
|
+
// Whether the build relies on Metro really transforming files (capture, frozen and load all do), which a cached
|
|
144
|
+
// transform would skip.
|
|
171
145
|
get needsTransforms() {
|
|
172
146
|
const config = this.#state?.config
|
|
173
|
-
|
|
174
|
-
return config.useLockfile || config.bundle
|
|
147
|
+
return config !== undefined && (config.useLockfile || config.bundle)
|
|
175
148
|
}
|
|
176
149
|
|
|
177
|
-
// Build a Metro `serializer.customSerializer`: captures the graph + preModules (which the
|
|
178
|
-
// experimental hook never sees), then delegates to base or reproduces Metro's default output.
|
|
179
150
|
customSerializer(baseSerializer = undefined) {
|
|
180
151
|
if (baseSerializer !== undefined && typeof baseSerializer !== 'function') {
|
|
181
152
|
throw new TypeError('StasisMetro.customSerializer(base?): base must be a function or omitted')
|
|
182
153
|
}
|
|
183
|
-
// Inert
|
|
184
|
-
// 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.
|
|
185
155
|
if (!this.#state) return baseSerializer
|
|
186
156
|
return (entryPoint, preModules, graph, options) => {
|
|
187
157
|
this.#run(graph, preModules)
|
|
188
|
-
|
|
189
|
-
return serialize(entryPoint, preModules, graph, options)
|
|
158
|
+
return (baseSerializer ?? metroDefaultSerializer())(entryPoint, preModules, graph, options)
|
|
190
159
|
}
|
|
191
160
|
}
|
|
192
161
|
|
|
193
|
-
// `serializer.experimentalSerializerHook`: observation-only, and LOWER COVERAGE -- Metro never
|
|
194
|
-
// passes preModules here, so the prepended polyfills/runtime go unattested. Prefer customSerializer.
|
|
195
162
|
serializerHook = (graph, _delta) => {
|
|
196
163
|
this.#run(graph)
|
|
197
164
|
}
|
|
198
165
|
|
|
199
166
|
#run(graph, preModules) {
|
|
200
167
|
if (!this.#state) return
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// the second serialization loudly (the first rebuild is the earliest refusal point Metro allows).
|
|
168
|
+
// Before the one-shot guard: load records nothing, and may re-serialize under a dev server.
|
|
169
|
+
if (this.#state.config.loadBundle) {
|
|
170
|
+
this.#assertEdges(graph, preModules)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
207
173
|
if (this.#ran) {
|
|
208
174
|
throw new Error(
|
|
209
175
|
'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
|
|
210
176
|
)
|
|
211
177
|
}
|
|
212
178
|
this.#ran = true
|
|
213
|
-
// Keyed on ownership: a withStasis-built instance already dropped the stores (see withStasis).
|
|
214
179
|
// Nothing here can detect a cache hit, so say it once rather than silently under-attest.
|
|
215
180
|
if (!this.#ownsConfig && this.needsTransforms) {
|
|
216
181
|
console.warn(
|
|
@@ -221,35 +186,27 @@ export class StasisMetro {
|
|
|
221
186
|
)
|
|
222
187
|
}
|
|
223
188
|
this.#capture(graph, preModules)
|
|
224
|
-
|
|
225
|
-
// The preload writes itself (hooks.js); standalone/sidecar States are written here. Reaching
|
|
226
|
-
// serialization means a clean build (a failed build throws first), so no error gate is needed.
|
|
189
|
+
// The preload writes itself; a failed build never reaches serialization.
|
|
227
190
|
if (this.#state !== State.preload) this.#state.write()
|
|
228
191
|
}
|
|
229
192
|
|
|
230
193
|
#capture(graph, preModules) {
|
|
231
194
|
const entries = entrySet(graph)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this.#captureModules(graph.dependencies.values(), entries)
|
|
236
|
-
// AFTER the graph so #seen already holds graph modules -- an auto-include that landed in the
|
|
237
|
-
// graph (asyncRequire, when the app uses dynamic import()) is deduped, not re-recorded.
|
|
195
|
+
const platform = graph.transformOptions?.platform ?? null
|
|
196
|
+
if (preModules) this.#captureModules(preModules, entries, platform)
|
|
197
|
+
this.#captureModules(graph.dependencies.values(), entries, platform)
|
|
238
198
|
this.#captureAutoIncludes()
|
|
239
|
-
|
|
240
|
-
//
|
|
199
|
+
this.#captureTransformPlugins()
|
|
200
|
+
// Last, past what is already captured: the native build inputs no module graph carries.
|
|
241
201
|
this.#captureNativeModules()
|
|
242
202
|
}
|
|
243
203
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// addFile already asserts UTF-8 for a non-resource (code) source, so no guard is needed here.
|
|
247
|
-
#recordCapture(full, source, { format, resource }) {
|
|
248
|
-
this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, reason: 'metro' })
|
|
204
|
+
#recordCapture(full, source, { format, resource, inferFormat = true, reason = 'metro' }) {
|
|
205
|
+
this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason })
|
|
249
206
|
}
|
|
250
207
|
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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.
|
|
253
210
|
#captureAutoIncludes() {
|
|
254
211
|
for (const specifier of AUTO_INCLUDES) {
|
|
255
212
|
let modPath
|
|
@@ -258,61 +215,44 @@ export class StasisMetro {
|
|
|
258
215
|
} catch {
|
|
259
216
|
continue
|
|
260
217
|
}
|
|
261
|
-
if (this.#seen.has(modPath)) continue
|
|
262
|
-
try {
|
|
263
|
-
this.#state.relative(modPath)
|
|
264
|
-
} catch {
|
|
265
|
-
continue
|
|
266
|
-
}
|
|
218
|
+
if (this.#seen.has(modPath) || !this.#within(modPath)) continue
|
|
267
219
|
this.#seen.add(modPath)
|
|
268
|
-
// Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
|
|
269
220
|
const source = realReadFileSync(modPath)
|
|
270
|
-
// Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
|
|
271
|
-
// the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
|
|
272
|
-
// would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
|
|
273
221
|
const format = classifyFormat(modPath, { content: source })
|
|
274
|
-
this.#recordCapture(modPath, source, { format, resource: format === undefined })
|
|
222
|
+
this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false, reason: 'run' })
|
|
275
223
|
}
|
|
276
224
|
}
|
|
277
225
|
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
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`.
|
|
228
|
+
#captureTransformPlugins() {
|
|
229
|
+
for (const file of metroTransformPluginFiles(this.#projectDir)) {
|
|
230
|
+
if (this.#seen.has(file) || !this.#within(file)) continue
|
|
231
|
+
this.#seen.add(file)
|
|
232
|
+
try {
|
|
233
|
+
require(file)
|
|
234
|
+
} catch { /* one that can't load can't run under Metro either: its bytes are still recorded */ }
|
|
235
|
+
this.#recordCapture(file, realReadFileSync(file), { reason: 'run' })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
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.
|
|
282
241
|
#captureNativeModules() {
|
|
283
242
|
const config = loadReactNativeConfig(this.#projectDir)
|
|
284
243
|
if (!config) return
|
|
285
244
|
const roots = new Set()
|
|
286
|
-
// Autolinked native deps: linked into the app regardless of whether their JS is imported.
|
|
287
245
|
for (const dep of Object.values(config.dependencies ?? {})) {
|
|
288
246
|
if (isNativeDependency(dep) && typeof dep.root === 'string' && isAbsolute(dep.root)) roots.add(dep.root)
|
|
289
247
|
}
|
|
290
|
-
// Native modules the app imports but autolinking never reports (manually integrated via Podfile).
|
|
291
248
|
for (const root of this.#reachedNativePackageRoots()) roots.add(root)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
for (const root of roots) {
|
|
298
|
-
try {
|
|
299
|
-
this.#state.relative(root) // asserts the package lives inside the project root
|
|
300
|
-
} catch {
|
|
301
|
-
continue
|
|
302
|
-
}
|
|
303
|
-
this.#captureNativeTree(root, true)
|
|
304
|
-
}
|
|
305
|
-
// RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
|
|
306
|
-
if (typeof rnPath === 'string' && isAbsolute(rnPath)) {
|
|
307
|
-
try {
|
|
308
|
-
this.#state.relative(rnPath)
|
|
309
|
-
for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
|
|
310
|
-
} catch { /* react-native resolved outside the project root -- unattestable */ }
|
|
311
|
-
}
|
|
249
|
+
const rnPath = typeof config.reactNativePath === 'string' && isAbsolute(config.reactNativePath) ? config.reactNativePath : null
|
|
250
|
+
if (rnPath !== null) roots.add(rnPath)
|
|
251
|
+
for (const root of roots) if (this.#within(root)) this.#captureNativeTree(root)
|
|
252
|
+
// RN core's `.js` build scripts, which the walk leaves to Metro but its graph never has.
|
|
253
|
+
if (rnPath !== null && this.#within(rnPath)) for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
|
|
312
254
|
}
|
|
313
255
|
|
|
314
|
-
// Attest a single vetted file (RN_CORE_INCLUDE_FILES) the tree walk skips; absent -> skipped.
|
|
315
|
-
// Tagged via the shared classifier like every other path (a `.js` build script -> code).
|
|
316
256
|
#captureNativeFile(full) {
|
|
317
257
|
if (this.#seen.has(full)) return
|
|
318
258
|
let source
|
|
@@ -326,160 +266,196 @@ export class StasisMetro {
|
|
|
326
266
|
this.#recordCapture(full, source, { format, resource: format === undefined })
|
|
327
267
|
}
|
|
328
268
|
|
|
329
|
-
//
|
|
330
|
-
// the manually-integrated native modules `react-native config` omits. Derived from #seen; RN core
|
|
331
|
-
// is skipped here because it's added to the walk explicitly (via reactNativePath).
|
|
269
|
+
// RN core is walked as reactNativePath.
|
|
332
270
|
#reachedNativePackageRoots() {
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
const nm = splitNodeModulesPath(abs)
|
|
336
|
-
if (!nm || nm.name === 'react-native') continue
|
|
337
|
-
pkgRoots.add(nm.dir)
|
|
338
|
-
}
|
|
339
|
-
return [...pkgRoots].filter((root) => this.#looksNative(root))
|
|
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))
|
|
340
273
|
}
|
|
341
274
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
275
|
+
// In the format nativeInputs gives (none for a codegen source, as Metro's code has none, so none is inferred).
|
|
276
|
+
#captureNativeTree(root) {
|
|
277
|
+
for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
|
|
278
|
+
this.#seen.add(file)
|
|
279
|
+
this.#recordCapture(file, source, { format, resource: action === 'resource', inferFormat: false })
|
|
280
|
+
}
|
|
345
281
|
}
|
|
346
282
|
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
283
|
+
// bundle=load: Metro resolved every import itself, so each edge must be the one the bundle attests, for the
|
|
284
|
+
// platform Metro builds.
|
|
285
|
+
#assertEdges(graph, preModules) {
|
|
286
|
+
const platform = graph.transformOptions?.platform ?? undefined
|
|
287
|
+
for (const module of [...(preModules ?? []), ...graph.dependencies.values()]) {
|
|
288
|
+
const modPath = diskPath(module)
|
|
289
|
+
if (modPath === undefined) continue
|
|
290
|
+
const url = pathToFileURL(modPath).toString()
|
|
291
|
+
for (const [specifier, target] of this.#edges(module)) this.#state.assertImport(url, specifier, target, { platform })
|
|
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
|
+
}
|
|
295
|
+
this.#assertAssetFiles(module, modPath, platform ?? null)
|
|
354
296
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
297
|
+
}
|
|
298
|
+
|
|
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.
|
|
301
|
+
#edges(module) {
|
|
302
|
+
const edges = []
|
|
303
|
+
for (const dep of module.dependencies?.values?.() ?? []) {
|
|
304
|
+
if (dep?.data?.stasisInserted === true) continue
|
|
305
|
+
const target = dep?.absolutePath
|
|
306
|
+
if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
|
|
307
|
+
if (classifyExtension(target, this.#resources) === 'unknown') continue
|
|
308
|
+
for (const specifier of dep.data?.stasisAsWritten ?? [dep.data?.name]) {
|
|
309
|
+
if (typeof specifier === 'string') edges.push([specifier, pathToFileURL(target).toString()])
|
|
361
310
|
}
|
|
362
311
|
}
|
|
363
|
-
return
|
|
312
|
+
return edges
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#captureModules(modules, entries, platform) {
|
|
316
|
+
for (const module of modules) {
|
|
317
|
+
const modPath = diskPath(module)
|
|
318
|
+
if (modPath === undefined) continue
|
|
319
|
+
const kind = classifyOrThrow('StasisMetro', modPath, this.#resources)
|
|
320
|
+
const url = pathToFileURL(modPath).toString()
|
|
321
|
+
for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
|
|
322
|
+
if (!this.#captureRead(modPath, kind, entries.has(modPath))) continue
|
|
323
|
+
for (const { file } of this.#consumedResources(module, modPath)) this.#captureRead(file, 'resource')
|
|
324
|
+
this.#captureAssetFiles(module, modPath, platform)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#within(path) {
|
|
329
|
+
return isPathWithin(this.#state.root, path)
|
|
364
330
|
}
|
|
365
331
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
#captureNativeTree(dir, atRoot = false) {
|
|
369
|
-
let entries
|
|
332
|
+
#realWithin(path) {
|
|
333
|
+
let real
|
|
370
334
|
try {
|
|
371
|
-
|
|
335
|
+
real = diskHost.realpath(path)
|
|
336
|
+
this.#realRoot ??= diskHost.realpath(this.#state.root)
|
|
372
337
|
} catch {
|
|
373
|
-
return
|
|
338
|
+
return null
|
|
374
339
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (
|
|
387
|
-
if (this.#
|
|
388
|
-
|
|
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' })
|
|
340
|
+
return this.#within(real) && isPathWithin(this.#realRoot, real) ? real : null
|
|
341
|
+
}
|
|
342
|
+
|
|
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 }
|
|
346
|
+
* #consumedResources(module, modPath) {
|
|
347
|
+
for (const specifier of module.output?.[0]?.data?.stasisConsumed ?? []) {
|
|
348
|
+
if (typeof specifier !== 'string' || !(isAbsolute(specifier) || /^\.\.?\//u.test(specifier))) continue
|
|
349
|
+
const read = resolve(dirname(modPath), specifier)
|
|
350
|
+
const file = diskHost.stat(read)?.isFile() ? this.#realWithin(read) : null
|
|
351
|
+
if (file === null) continue
|
|
352
|
+
if (classifyOrThrow('StasisMetro', file, this.#resources, `'${file}', which a Babel plugin inlines into '${modPath}'`) === 'code') continue
|
|
353
|
+
yield { file, read }
|
|
397
354
|
}
|
|
398
355
|
}
|
|
399
356
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
const url = pathToFileURL(modPath).toString()
|
|
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
|
|
362
|
+
this.#seen.add(path)
|
|
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
|
|
369
|
+
}
|
|
415
370
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
}
|
|
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.
|
|
373
|
+
#assetFiles(module, modPath, platform) {
|
|
374
|
+
return module.output?.[0]?.type === 'js/module/asset' ? metroAssetFiles(modPath, platform, this.#assetDirHost()) : []
|
|
375
|
+
}
|
|
426
376
|
|
|
427
|
-
|
|
428
|
-
|
|
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).
|
|
379
|
+
#assetDirHost() {
|
|
380
|
+
const state = this.#state
|
|
381
|
+
if (!state.config.loadBundle || state.config.fs !== 'async') return diskHost
|
|
382
|
+
return {
|
|
383
|
+
readdir: (dir) => state.getFsDirFamily(pathToFileURL(dir).toString())?.map((name) => ({ name })) ?? diskHost.readdir(dir),
|
|
384
|
+
stat: (path) => diskHost.stat(path) ?? (state.getFsStatFamily(pathToFileURL(path).toString()) === 'file' ? FILE_STAT : null),
|
|
385
|
+
}
|
|
386
|
+
}
|
|
429
387
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
388
|
+
#bundleDirHost() {
|
|
389
|
+
return (this.#bundleDirs ??= bundleDirHost(this.#state))
|
|
390
|
+
}
|
|
391
|
+
|
|
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).
|
|
394
|
+
#captureAssetFiles(module, modPath, platform) {
|
|
395
|
+
for (const read of this.#assetFiles(module, modPath, platform)) {
|
|
396
|
+
const kind = classifyExtension(read, this.#resources)
|
|
397
|
+
if (kind !== 'unknown' && this.#realWithin(read) !== null) this.#captureRead(read, kind)
|
|
438
398
|
}
|
|
439
399
|
}
|
|
400
|
+
|
|
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.
|
|
404
|
+
#assertAssetFiles(module, modPath, platform) {
|
|
405
|
+
const read = this.#assetFiles(module, modPath, platform)
|
|
406
|
+
if (read.length === 0) return
|
|
407
|
+
const what = `a file of the asset '${modPath}' Metro builds`
|
|
408
|
+
for (const path of read) {
|
|
409
|
+
if (diskHost.stat(path)?.isFile()) this.#assertOnDisk({ file: path, read: path }, { what, servedBy: ['async'] })
|
|
410
|
+
}
|
|
411
|
+
if (!inScope(this.#state, pathToFileURL(modPath).toString())) return
|
|
412
|
+
for (const file of metroAssetFiles(modPath, platform, this.#bundleDirHost())) {
|
|
413
|
+
if (read.includes(file)) continue
|
|
414
|
+
throw new Error(`StasisMetro: '${file}', ${what} as the bundle carries it, is gone -- Metro would build the asset without it under load; restore it`)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
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.
|
|
420
|
+
#assertOnDisk({ file, read }, { what, servedBy }) {
|
|
421
|
+
const state = this.#state
|
|
422
|
+
const url = pathToFileURL(file).toString()
|
|
423
|
+
if (!inScope(state, url)) return
|
|
424
|
+
if (servedBy.includes(state.config.fs) && state.getFsFileFamily(pathToFileURL(read).toString()) !== undefined) return
|
|
425
|
+
const { source } = state.getFile(url)
|
|
426
|
+
if (realReadFileSync(file).equals(Buffer.isBuffer(source) ? source : Buffer.from(source))) return
|
|
427
|
+
throw new Error(
|
|
428
|
+
`StasisMetro: '${file}', ${what}, differs on disk from the bundle -- ` +
|
|
429
|
+
'Metro\'s build reads it from disk under load; restore it, or run with --fs to serve it from the bundle'
|
|
430
|
+
)
|
|
431
|
+
}
|
|
440
432
|
}
|
|
441
433
|
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
// still produces the bundle, and with Metro's transform cache dropped so the workers really run.
|
|
445
|
-
// Pure -- returns a new object, doesn't mutate `config`.
|
|
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.
|
|
446
436
|
export function withStasis(config = {}, options = {}) {
|
|
447
437
|
const stasis = new StasisMetro(options, { ownsConfig: true })
|
|
448
438
|
const existing = config.serializer?.customSerializer ?? undefined
|
|
449
|
-
// Metro's transform cache
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
//
|
|
454
|
-
// server silently decides what gets attested: a capture over a warm cache drops the toolchain
|
|
455
|
-
// (exit 0, no warning) and a later frozen run rejects the lockfile it wrote, while a frozen run
|
|
456
|
-
// over a warm cache passes vacuously (it verifies transforms nobody performed).
|
|
457
|
-
//
|
|
458
|
-
// Dropped in EVERY active mode, load included, so the module set can't diverge between record and
|
|
459
|
-
// replay. `new Transformer` calls getTransformCacheKey() only when the cache is enabled
|
|
460
|
-
// (metro/src/DeltaBundler/Transformer.js: `this._cache.isDisabled ? '' : ...`), and that call tree
|
|
461
|
-
// resolves the transformer plus its plugins' cache-key files. Disabled during capture, those edges
|
|
462
|
-
// are never recorded; left enabled under load they resolve for real and getImport fails closed on
|
|
463
|
-
// a bundle that cannot know them ("Cannot find module 'metro-transform-worker' imported from
|
|
464
|
-
// .../getTransformCacheKey.js" -- Metro reports it as "Failed to construct transformer").
|
|
465
|
-
//
|
|
466
|
-
// Slower, but the loaded set stops depending on what ran before. These builds are one-shot anyway.
|
|
467
|
-
const dropCache = stasis.needsTransforms
|
|
468
|
-
// `undefined` is Metro's own default store, which the user never chose; an array or a
|
|
469
|
-
// `(MetroCache) => stores` factory is theirs, so replacing it is worth saying out loud.
|
|
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.
|
|
470
444
|
const stores = config.cacheStores
|
|
471
|
-
if (
|
|
445
|
+
if (active && (typeof stores === 'function' || stores?.length > 0)) {
|
|
472
446
|
console.warn(
|
|
473
447
|
'[stasis] StasisMetro: ignoring the `cacheStores` in your Metro config for this build -- a cached ' +
|
|
474
448
|
'transform skips the worker, leaving the toolchain it loads unattested'
|
|
475
449
|
)
|
|
476
450
|
}
|
|
451
|
+
// Metro hands `transformer` to each worker's transformer as is.
|
|
477
452
|
return {
|
|
478
453
|
...config,
|
|
479
|
-
...(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
},
|
|
454
|
+
...(active && {
|
|
455
|
+
cacheStores: [],
|
|
456
|
+
transformerPath: TRANSFORMER,
|
|
457
|
+
transformer: { ...config.transformer, stasisBaseTransformerPath: config.transformerPath },
|
|
458
|
+
}),
|
|
459
|
+
serializer: { ...config.serializer, customSerializer: stasis.customSerializer(existing) },
|
|
484
460
|
}
|
|
485
461
|
}
|