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