@exodus/stasis-plugins 1.0.0-beta.2 → 1.0.0-beta.5
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/README.md +1 -1
- package/package.json +8 -6
- package/src/esbuild.js +62 -112
- package/src/metro-transformer.js +32 -72
- package/src/metro.js +315 -253
- package/src/plugins.js +49 -87
- package/src/rollup.js +327 -0
- package/src/webpack.js +84 -294
package/src/metro.js
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { isUtf8 } from 'node:buffer'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
3
4
|
import { existsSync } from 'node:fs'
|
|
4
5
|
import { createRequire } from 'node:module'
|
|
5
|
-
import { isAbsolute } from 'node:path'
|
|
6
|
+
import { isAbsolute, join } from 'node:path'
|
|
6
7
|
import { pathToFileURL } from 'node:url'
|
|
7
8
|
|
|
8
9
|
import { resolvePluginState } from './plugins.js'
|
|
9
10
|
import { State } from '@exodus/stasis-core/state'
|
|
10
|
-
import { realReadFileSync } from '@exodus/stasis-core/state-util'
|
|
11
|
-
import { classifyExtension } from '@exodus/stasis-core/util'
|
|
11
|
+
import { realReadFileSync, realReaddirSync } from '@exodus/stasis-core/state-util'
|
|
12
|
+
import { RN_CORE_INCLUDE_FILES, classifyExtension, classifyFormat, classifyNativeCapture, isExcludedNativeDir, isNativeArtifact, isPodspec, isSkippedNativeWalkDir, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util'
|
|
12
13
|
|
|
13
14
|
const require = createRequire(import.meta.url)
|
|
14
15
|
|
|
15
|
-
// `graph.entryPoints` is a
|
|
16
|
-
// an `Array<string>` before ~0.71; it has never been a Map). Normalize to a Set so
|
|
17
|
-
// `isEntry` is a single `.has`. The one-shot `metro build` passes the graph but no separate
|
|
18
|
-
// entry arg, and `customSerializer`'s separate `entryPoint` string names only the PRIMARY
|
|
19
|
-
// entry, so `graph.entryPoints` is the authoritative full set (matters for multi-entry
|
|
20
|
-
// builds). An absent/odd shape yields an empty set -- nothing marked an entry rather than
|
|
21
|
-
// throwing, matching how the other plugins treat an entry they can't positively identify.
|
|
16
|
+
// Metro's `graph.entryPoints` is a Set now, an Array before ~0.71; normalize to a Set.
|
|
22
17
|
function entrySet(graph) {
|
|
23
18
|
const ep = graph?.entryPoints
|
|
24
19
|
if (ep instanceof Set) return ep
|
|
@@ -26,37 +21,66 @@ function entrySet(graph) {
|
|
|
26
21
|
return new Set()
|
|
27
22
|
}
|
|
28
23
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// from the project, and skipped when they don't (not every Metro project ships them):
|
|
32
|
-
// - metro-runtime/src/modules/asyncRequire.js: Metro's default
|
|
33
|
-
// `transformer.asyncRequireModulePath`. The transform injects it to implement
|
|
34
|
-
// dynamic import(), so it ships in bundles without necessarily appearing as a
|
|
35
|
-
// module of the graph handed to the serializer.
|
|
36
|
-
// - @react-native-community/cli/setup_env.sh: sourced by the React Native CLI's
|
|
37
|
-
// native build phases (Xcode / Gradle) around the JS bundle step -- build-shaping
|
|
38
|
-
// toolchain that is never part of the JS module graph at all.
|
|
39
|
-
// A `resource: true` entry bypasses the `resources` allowlist: this is a stasis-vetted
|
|
40
|
-
// list of SPECIFIC files, not a user-widened extension class. Code entries ride the
|
|
41
|
-
// normal loader-format inference. Extend the list here, not via an option -- an option
|
|
42
|
-
// would make the attested set configuration-dependent, which is exactly what
|
|
43
|
-
// auto-include exists to avoid.
|
|
24
|
+
// Files a React Native build requires that Metro's module graph never carries; attested (tagged by
|
|
25
|
+
// the shared classifier, see #captureAutoIncludes) when they resolve from the project.
|
|
44
26
|
const AUTO_INCLUDES = [
|
|
45
|
-
|
|
46
|
-
|
|
27
|
+
'metro-runtime/src/modules/asyncRequire.js',
|
|
28
|
+
'@react-native-community/cli/setup_env.sh',
|
|
47
29
|
]
|
|
48
30
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
const WORKER_TOOLCHAIN = 'babel.config.js, @babel/core, the RN preset + plugins'
|
|
34
|
+
|
|
35
|
+
// Run `react-native config` (RN's autolinking resolver) for native deps. Null when the RN CLI
|
|
36
|
+
// isn't installed; THROWS when present but failing -- silently under-attesting breaks frozen runs.
|
|
37
|
+
function loadReactNativeConfig(projectDir) {
|
|
38
|
+
let cliPath
|
|
39
|
+
try {
|
|
40
|
+
cliPath = require.resolve('react-native/cli.js', { paths: [projectDir] })
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
// Run the child as vanilla Node: strip stasis's env and any inherited loader, else it joins
|
|
45
|
+
// this capture nondeterministically or aborts on a config it can't satisfy.
|
|
46
|
+
const env = { ...process.env }
|
|
47
|
+
for (const key of Object.keys(env)) {
|
|
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
|
+
}
|
|
60
|
+
if (res.status !== 0) {
|
|
61
|
+
const detail = (res.stderr || '').trim()
|
|
62
|
+
throw new Error(
|
|
63
|
+
`StasisMetro: 'react-native config' exited ${res.status}${res.signal ? ` on signal ${res.signal}` : ''}` +
|
|
64
|
+
`${detail ? `: ${detail}` : ''}`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(res.stdout)
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
throw new Error("StasisMetro: couldn't parse 'react-native config' output as JSON", { cause })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Native when at least one platform config is non-null; an all-null dep is JS-only (already in the graph).
|
|
75
|
+
function isNativeDependency(dep) {
|
|
76
|
+
const platforms = dep?.platforms
|
|
77
|
+
if (!platforms || typeof platforms !== 'object') return false
|
|
78
|
+
return Object.values(platforms).some((p) => p != null)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Metro has no public default-serializer export; reproduce its default output via internal
|
|
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.
|
|
60
84
|
let metroDefault
|
|
61
85
|
function metroDefaultSerializer() {
|
|
62
86
|
if (metroDefault) return metroDefault
|
|
@@ -77,179 +101,88 @@ function metroDefaultSerializer() {
|
|
|
77
101
|
)
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
|
-
//
|
|
81
|
-
// either as the namespace itself or under `.default`.
|
|
104
|
+
// Metro ships CJS; require exposes the fn as the namespace or under `.default`.
|
|
82
105
|
baseJSBundle = baseJSBundle.default ?? baseJSBundle
|
|
83
106
|
bundleToString = bundleToString.default ?? bundleToString
|
|
84
|
-
// Return the `.code` string (bundleToString yields { code, metadata }); a string is a
|
|
85
|
-
// valid customSerializer return and Metro regenerates the source map itself.
|
|
86
107
|
metroDefault = (entryPoint, preModules, graph, options) =>
|
|
87
108
|
bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
|
|
88
109
|
return metroDefault
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
// Stasis plugin for
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// THIS PLUGIN IS THE CAPTURE HALF -- LOAD LIVES IN A SEPARATE TRANSFORMER:
|
|
104
|
-
// The other plugins serve bundle bytes back into the build because their bundler
|
|
105
|
-
// reads + transforms files in the same process the bundle lives in (webpack's
|
|
106
|
-
// inputFileSystem wrapper, esbuild's onLoad). Metro reads + transforms in workers,
|
|
107
|
-
// before serialization, so the serializer can't inject bytes into the transform.
|
|
108
|
-
// Load is instead handled per-worker by the companion `./metro-transformer.js`
|
|
109
|
-
// (wired as Metro's top-level `transformerPath`): unlike capture, load only ever
|
|
110
|
-
// READS the immutable bundle and every worker reads the same bytes, so there is no
|
|
111
|
-
// cross-worker state to merge. Under bundle=load this serializer is therefore a
|
|
112
|
-
// transparent PASS-THROUGH (captures nothing, writes nothing, delegates to the
|
|
113
|
-
// base serializer), mirroring the transformer, which passes through in every
|
|
114
|
-
// non-load mode. Wire BOTH halves permanently; the mode picks the active one.
|
|
115
|
-
// That matters because metro.config.js is itself attested at capture, so a load
|
|
116
|
-
// run must execute it unedited. Capture modes (lock/bundle add|replace|frozen,
|
|
117
|
-
// and lock=none/ignore) all capture here.
|
|
118
|
-
//
|
|
119
|
-
// USAGE (metro.config.js) -- prefer withStasis; wire the load half permanently alongside it:
|
|
120
|
-
// const { withStasis } = require('@exodus/stasis/metro')
|
|
121
|
-
// module.exports = withStasis({
|
|
122
|
-
// ...require('./metro.config.base'),
|
|
123
|
-
// transformerPath: require.resolve('@exodus/stasis/metro-transformer'), // the load half
|
|
124
|
-
// }, { /* scope, lock, ... */ })
|
|
125
|
-
//
|
|
126
|
-
// Then CAPTURE with child-process forwarding ON -- Metro transforms in worker processes, so
|
|
127
|
-
// `--child-process` is REQUIRED on a capture run to attest the toolchain those workers load
|
|
128
|
-
// (babel.config.js, @babel/core, the RN preset + plugins). The plugin throws at config time if
|
|
129
|
-
// you forget it on a writing run: `stasis run --lock=add --child-process -- <metro build cmd>`.
|
|
130
|
-
// A later `--lock=frozen` verify needs NO flag (verification is per-process, independent of the
|
|
131
|
-
// shard channel). withStasis wires the STABLE `serializer.customSerializer`, which is the only
|
|
132
|
-
// Metro surface that receives `preModules` (the polyfills/runtime Metro prepends to every
|
|
133
|
-
// bundle -- real shipped files). It wraps an existing customSerializer if you have one,
|
|
134
|
-
// otherwise reproduces Metro's default output. For manual wiring use
|
|
135
|
-
// `new StasisMetro(opts).customSerializer(base?)`. `serializerHook` (the experimental
|
|
136
|
-
// hook) is a lower-coverage fallback -- see its note.
|
|
137
|
-
//
|
|
138
|
-
// AUTO-INCLUDED FILES: capture also attests the fixed AUTO_INCLUDES list (see it above
|
|
139
|
-
// the class) -- files a React Native build requires that the serializer's graph never
|
|
140
|
-
// reliably carries (Metro's asyncRequire module, the RN CLI's setup_env.sh). Each is
|
|
141
|
-
// attested when it resolves from the project and skipped when it doesn't, through the
|
|
142
|
-
// same addFile path as graph modules, so frozen verification covers them fail-closed.
|
|
143
|
-
// NOTE: a lockfile captured before this list existed doesn't attest these files, so a
|
|
144
|
-
// frozen run against it fails once they're present -- recapture (lock=replace) once.
|
|
145
|
-
//
|
|
146
|
-
// KNOWN LIMITATIONS (Metro-specific coverage gaps -- documented, not silently ignored):
|
|
147
|
-
// - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer
|
|
148
|
-
// per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe
|
|
149
|
-
// would otherwise keep attesting a file's first-build bytes after an edit changed them.
|
|
150
|
-
// Metro gives the serializer no watch signal on the first build, so the first rebuild is the
|
|
151
|
-
// earliest refusal point. Load mode is unaffected and runs fine under a dev server --
|
|
152
|
-
// the serializer passes through and the worker transformer only reads immutable
|
|
153
|
-
// attested bytes.
|
|
154
|
-
// - Child-process capture is best-effort within that one shot. A writing plugin opts the
|
|
155
|
-
// build's workers into the loader's SIGTERM shard flush (EXODUS_STASIS_SHARD_SIGNAL_FLUSH,
|
|
156
|
-
// set in the constructor), so a worker force-killed by jest-worker's forceExit -- SIGTERM
|
|
157
|
-
// 500ms after END, routine for a transform worker whose event loop doesn't drain --
|
|
158
|
-
// contributes its shard best-effort: the flush must complete inside forceExit's follow-up
|
|
159
|
-
// 500ms SIGTERM->SIGKILL window. A worker killed any OTHER way still loses its shard
|
|
160
|
-
// SILENTLY at capture time -- SIGKILL (jest-worker's memory-limit killChild, a
|
|
161
|
-
// killSignal:'SIGKILL' override), a non-SIGTERM signal (group SIGINT/SIGHUP, another
|
|
162
|
-
// killSignal override; the flush handler is SIGTERM-only), or a nested orchestration's
|
|
163
|
-
// Metro MAIN process (itself a capturing child, it snapshots its config before this
|
|
164
|
-
// constructor sets the flag, so only its descendants are covered). A later frozen run
|
|
165
|
-
// catches every such gap (fail-closed), but nothing warns during capture. And it relies
|
|
166
|
-
// on the loader's exit/signal-time writes, which standalone (no stasis loader) Metro
|
|
167
|
-
// never reaches -- it mints no shard dir and produces no toolchain-complete lockfile.
|
|
168
|
-
// Use a one-shot `metro build` under `stasis run --child-process` to capture, then
|
|
169
|
-
// verify with `--lock=frozen`.
|
|
170
|
-
// - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
|
|
171
|
-
// by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
|
|
172
|
-
// scales/files arrays), NOT as separate graph modules, so only the base file is attested
|
|
173
|
-
// here. Assets pulled in by the native build via `react-native.config.js` (e.g. fonts
|
|
174
|
-
// through `react-native-asset`) aren't in Metro's JS graph at all and aren't covered.
|
|
175
|
-
// - Platform-specific lockfiles: edges are recorded under the as-written specifier
|
|
176
|
-
// ('./Foo') resolved to the platform variant Metro picked ('Foo.ios.js'), all under the
|
|
177
|
-
// '*' conditions key. A lockfile captured for one platform won't match a frozen run for
|
|
178
|
-
// another -- capture/verify per platform. It fails CLOSED (rejects, never silently
|
|
179
|
-
// accepts a mismatched resolution).
|
|
112
|
+
// Stasis capture plugin for Metro (React Native). Hooks Metro's serializer, which runs ONCE in
|
|
113
|
+
// 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 LOAD half is the companion
|
|
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).
|
|
180
123
|
export class StasisMetro {
|
|
181
124
|
#seen = new Set()
|
|
182
125
|
#state
|
|
183
126
|
#resources
|
|
184
127
|
|
|
185
|
-
// Base dir
|
|
186
|
-
// same cwd resolvePluginState anchors the State at, and an in-or-under-root path by
|
|
187
|
-
// State's own root detection (which walks UP from it), so Node's node_modules walk-up
|
|
188
|
-
// from here also covers hoisted workspace layouts. Snapshotted at construction so a
|
|
189
|
-
// later chdir can't skew what a capture attests.
|
|
128
|
+
// Base dir for resolution; snapshotted at construction so a later chdir can't skew capture.
|
|
190
129
|
#projectDir = process.cwd()
|
|
191
130
|
|
|
192
|
-
// Whether this instance already
|
|
193
|
-
// one-shot `metro build`; a dev server re-invokes it per rebuild, which capture refuses --
|
|
194
|
-
// see the guard in #run.
|
|
131
|
+
// Whether this instance already captured; the dev-server rebuild guard in #run trips on the second.
|
|
195
132
|
#ran = false
|
|
196
133
|
|
|
197
|
-
|
|
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
|
+
#ownsConfig
|
|
138
|
+
|
|
139
|
+
constructor(options = {}, { ownsConfig = false } = {}) {
|
|
198
140
|
const { state } = resolvePluginState('StasisMetro', options, process.cwd())
|
|
199
|
-
this.#state = state // null when the plugin should be inert (Rule 7)
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// unattested and a later frozen/load run rejects it. Fail loudly at config time rather than
|
|
205
|
-
// silently emit an incomplete lockfile. Gate on the SAME predicate as the shard channel itself
|
|
206
|
-
// (writeLockfile || writeBundle, i.e. lock/bundle add|replace) so the flag is demanded exactly
|
|
207
|
-
// when forwarding does something: a frozen/ignore/none run verifies per-process and needs no
|
|
208
|
-
// channel (the CLI frozen tests pass without the flag), and load mode reads the bundle via the
|
|
209
|
-
// companion transformer -- none of those should be forced to pass --child-process.
|
|
141
|
+
this.#state = state // null when the plugin should be inert (Rule 0 or Rule 7)
|
|
142
|
+
this.#ownsConfig = ownsConfig
|
|
143
|
+
// A capture-that-writes REQUIRES --child-process: the worker toolchain (babel, RN preset) is
|
|
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).
|
|
210
146
|
if (state && (state.config.writeLockfile || state.config.writeBundle)) {
|
|
211
147
|
assert.ok(
|
|
212
148
|
state.config.childProcess,
|
|
213
149
|
'StasisMetro: child-process capture must be enabled -- Metro transforms in worker processes, ' +
|
|
214
|
-
|
|
150
|
+
`and without it the toolchain they load (${WORKER_TOOLCHAIN}) ` +
|
|
215
151
|
'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
|
|
216
152
|
'or "childProcess": true in stasis.config.json.'
|
|
217
153
|
)
|
|
218
|
-
// Opt this build's children into the loader's SIGTERM shard flush
|
|
219
|
-
// forceExit
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
// Metro's transform workers are the intended audience, but a codegen fork the build
|
|
223
|
-
// spawns rides along; the loader's own gates (child + shard forwarding) bound what it
|
|
224
|
-
// can do there to a flush-then-redeliver on SIGTERM. The plugin can't act inside the
|
|
225
|
-
// workers (only the loader runs there), so an env opt-in is the seam; scoped HERE,
|
|
226
|
-
// not globally, because changing a child's default-kill disposition is otherwise the
|
|
227
|
-
// user's domain. Two deliberate softenings:
|
|
228
|
-
// - keyed on the PRELOAD where one exists: the shard channel is minted by, and the
|
|
229
|
-
// children's capture mode follows, the preload's config -- a Rule-6 sidecar's own
|
|
230
|
-
// bundle mode says nothing about either, and setting the flag under a frozen
|
|
231
|
-
// preload would claim an opt-in that can never engage;
|
|
232
|
-
// - `||=`, not `=`: an explicit ambient opt-out ('0'/'false', envBool's disable
|
|
233
|
-
// values) is the user's call and is honored; only unset/empty is claimed.
|
|
154
|
+
// Opt this build's children into the loader's SIGTERM shard flush (handler in hooks.js) so
|
|
155
|
+
// jest-worker forceExit doesn't silently drop a non-draining worker's shard. Key on the
|
|
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.
|
|
234
158
|
const channel = State.preload ?? state
|
|
235
159
|
if (channel.config.writeLockfile || channel.config.writeBundle) {
|
|
236
160
|
process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1'
|
|
237
161
|
}
|
|
238
162
|
}
|
|
239
|
-
//
|
|
240
|
-
// resolvePluginState); cache the resolved Set for the per-file classify hot path.
|
|
163
|
+
// Cache the resolved resources Set for the per-file classify hot path.
|
|
241
164
|
this.#resources = state?.config.resources ?? new Set()
|
|
242
165
|
}
|
|
243
166
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
167
|
+
// True when this build's guarantees depend on Metro actually TRANSFORMING files: a capture records
|
|
168
|
+
// what the workers load, frozen checks it, and load REPLAYS it -- all three break if a cached
|
|
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.
|
|
171
|
+
get needsTransforms() {
|
|
172
|
+
const config = this.#state?.config
|
|
173
|
+
if (!config) return false
|
|
174
|
+
return config.useLockfile || config.bundle
|
|
175
|
+
}
|
|
176
|
+
|
|
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.
|
|
249
179
|
customSerializer(baseSerializer = undefined) {
|
|
250
180
|
if (baseSerializer !== undefined && typeof baseSerializer !== 'function') {
|
|
251
181
|
throw new TypeError('StasisMetro.customSerializer(base?): base must be a function or omitted')
|
|
252
182
|
}
|
|
183
|
+
// Inert (#state === null): hand the base straight back -- the wrapper below would otherwise
|
|
184
|
+
// fall back to metroDefaultSerializer(), loading Metro internals a do-nothing plugin mustn't touch.
|
|
185
|
+
if (!this.#state) return baseSerializer
|
|
253
186
|
return (entryPoint, preModules, graph, options) => {
|
|
254
187
|
this.#run(graph, preModules)
|
|
255
188
|
const serialize = baseSerializer ?? metroDefaultSerializer()
|
|
@@ -257,105 +190,218 @@ export class StasisMetro {
|
|
|
257
190
|
}
|
|
258
191
|
}
|
|
259
192
|
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// `customSerializer`. LOWER COVERAGE: the hook's signature is `(graph, delta)`; Metro never
|
|
263
|
-
// passes `preModules` to it, so the prepended polyfills/runtime go UNATTESTED. Prefer
|
|
264
|
-
// withStasis/customSerializer. Bound so it can be passed by reference.
|
|
193
|
+
// `serializer.experimentalSerializerHook`: observation-only, and LOWER COVERAGE -- Metro never
|
|
194
|
+
// passes preModules here, so the prepended polyfills/runtime go unattested. Prefer customSerializer.
|
|
265
195
|
serializerHook = (graph, _delta) => {
|
|
266
196
|
this.#run(graph)
|
|
267
197
|
}
|
|
268
198
|
|
|
269
199
|
#run(graph, preModules) {
|
|
270
200
|
if (!this.#state) return
|
|
271
|
-
// bundle=load: pass through
|
|
272
|
-
//
|
|
273
|
-
// in load mode (see the class note). Checked BEFORE the one-shot guard: load captures
|
|
274
|
-
// nothing and legitimately re-serializes per rebuild under a dev server.
|
|
201
|
+
// bundle=load: pass through (load is served by the worker transformer). Checked BEFORE the
|
|
202
|
+
// one-shot guard -- load captures nothing and legitimately re-serializes under a dev server.
|
|
275
203
|
if (this.#state.config.loadBundle) return
|
|
276
|
-
// Watch/dev-server capture is
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// file's bytes changed the plugin would skip re-capture: the emitted bundle would use the
|
|
280
|
-
// new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones. The
|
|
281
|
-
// serializer fires exactly once per one-shot `metro build`; only a dev server
|
|
282
|
-
// (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on
|
|
283
|
-
// the FIRST build, so the first rebuild is the earliest point capture can refuse. (Load
|
|
284
|
-
// mode has no such hazard and legitimately runs under a dev server -- it passed through
|
|
285
|
-
// above, and the companion worker transformer only READS immutable attested bytes.)
|
|
204
|
+
// Watch/dev-server capture is unsupported: dedupe is keyed by PATH not content, so a rebuild
|
|
205
|
+
// with changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Refuse
|
|
206
|
+
// the second serialization loudly (the first rebuild is the earliest refusal point Metro allows).
|
|
286
207
|
if (this.#ran) {
|
|
287
208
|
throw new Error(
|
|
288
209
|
'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
|
|
289
210
|
)
|
|
290
211
|
}
|
|
291
212
|
this.#ran = true
|
|
213
|
+
// Keyed on ownership: a withStasis-built instance already dropped the stores (see withStasis).
|
|
214
|
+
// Nothing here can detect a cache hit, so say it once rather than silently under-attest.
|
|
215
|
+
if (!this.#ownsConfig && this.needsTransforms) {
|
|
216
|
+
console.warn(
|
|
217
|
+
"[stasis] StasisMetro: wired without withStasis(), so Metro's transform cache is outside " +
|
|
218
|
+
`stasis's control -- a cached transform skips the worker and the toolchain it loads ` +
|
|
219
|
+
`(${WORKER_TOOLCHAIN}) goes unattested. Wrap your config in withStasis(), or set ` +
|
|
220
|
+
'`cacheStores: []` yourself for every stasis build.'
|
|
221
|
+
)
|
|
222
|
+
}
|
|
292
223
|
this.#capture(graph, preModules)
|
|
293
224
|
|
|
294
|
-
// The
|
|
295
|
-
//
|
|
296
|
-
// serializer only runs once Metro has successfully built + transformed the whole graph
|
|
297
|
-
// (a failed build throws before serialization), so this is the clean-build equivalent of
|
|
298
|
-
// webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate -- and it runs at
|
|
299
|
-
// most once per instance (the rebuild guard above), so this write is the one-shot build's
|
|
300
|
-
// single flush.
|
|
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.
|
|
301
227
|
if (this.#state !== State.preload) this.#state.write()
|
|
302
228
|
}
|
|
303
229
|
|
|
304
230
|
#capture(graph, preModules) {
|
|
305
231
|
const entries = entrySet(graph)
|
|
306
|
-
// preModules first (Metro's prepended polyfills/runtime
|
|
307
|
-
//
|
|
308
|
-
// synthetic ones like __prelude__ are skipped by the existsSync guard below). Then the
|
|
309
|
-
// app module graph.
|
|
232
|
+
// preModules first (Metro's prepended polyfills/runtime; synthetic ones like __prelude__ are
|
|
233
|
+
// skipped by the existsSync guard below), then the app graph.
|
|
310
234
|
if (preModules) this.#captureModules(preModules, entries)
|
|
311
235
|
this.#captureModules(graph.dependencies.values(), entries)
|
|
312
|
-
// AFTER the graph so #seen already holds
|
|
313
|
-
//
|
|
314
|
-
// import()) is deduped instead of re-recorded.
|
|
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.
|
|
315
238
|
this.#captureAutoIncludes()
|
|
239
|
+
// AFTER the graph + auto-includes (same dedupe reason): the native pass only adds the native
|
|
240
|
+
// build-input surface (podspec/gradle/sources) that no module graph carries.
|
|
241
|
+
this.#captureNativeModules()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Shared epilogue for the classify-driven capture paths: record under the given format/resource.
|
|
245
|
+
// `format` null/undefined lets addFile pick (loader format for JS, byte-derived for a resource);
|
|
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' })
|
|
316
249
|
}
|
|
317
250
|
|
|
318
|
-
// Attest the AUTO_INCLUDES list
|
|
319
|
-
//
|
|
320
|
-
// nothing to attest), a file already captured as a graph module this build, and a
|
|
321
|
-
// resolution landing outside the project root (unattestable -- lockfile/bundle keys
|
|
322
|
-
// are root-relative; same out-of-scope stance as the load transformer's inScope).
|
|
323
|
-
// Everything that IS attested goes through the exact addFile path graph modules use,
|
|
324
|
-
// so frozen runs verify these files fail-closed like any other capture.
|
|
251
|
+
// Attest the AUTO_INCLUDES list. Per entry, skip: unresolvable, already-seen, or resolving
|
|
252
|
+
// outside the project root (keys are root-relative). Attested entries ride the normal addFile path.
|
|
325
253
|
#captureAutoIncludes() {
|
|
326
|
-
for (const
|
|
254
|
+
for (const specifier of AUTO_INCLUDES) {
|
|
327
255
|
let modPath
|
|
328
256
|
try {
|
|
329
257
|
modPath = require.resolve(specifier, { paths: [this.#projectDir] })
|
|
330
258
|
} catch {
|
|
331
|
-
continue
|
|
259
|
+
continue
|
|
332
260
|
}
|
|
333
261
|
if (this.#seen.has(modPath)) continue
|
|
334
262
|
try {
|
|
335
263
|
this.#state.relative(modPath)
|
|
336
264
|
} catch {
|
|
337
|
-
continue
|
|
265
|
+
continue
|
|
338
266
|
}
|
|
339
267
|
this.#seen.add(modPath)
|
|
340
|
-
// Real reader
|
|
341
|
-
// not a program fs read -- it must not be re-recorded by a --fs patched reader.
|
|
268
|
+
// Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
|
|
342
269
|
const source = realReadFileSync(modPath)
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
const format = classifyFormat(modPath, { content: source })
|
|
274
|
+
this.#recordCapture(modPath, source, { format, resource: format === undefined })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Attest each native dependency's build-input surface. Discovery is `react-native config`; each
|
|
279
|
+
// dep root is walked (isSkippedNativeWalkDir pruned) and its native sources are attested as code,
|
|
280
|
+
// other assets as resources, plus each package.json. Node-runnable JS/TS is skipped (already in
|
|
281
|
+
// Metro's graph). Skipped when the RN CLI is absent or a root is outside root.
|
|
282
|
+
#captureNativeModules() {
|
|
283
|
+
const config = loadReactNativeConfig(this.#projectDir)
|
|
284
|
+
if (!config) return
|
|
285
|
+
const roots = new Set()
|
|
286
|
+
// Autolinked native deps: linked into the app regardless of whether their JS is imported.
|
|
287
|
+
for (const dep of Object.values(config.dependencies ?? {})) {
|
|
288
|
+
if (isNativeDependency(dep) && typeof dep.root === 'string' && isAbsolute(dep.root)) roots.add(dep.root)
|
|
289
|
+
}
|
|
290
|
+
// Native modules the app imports but autolinking never reports (manually integrated via Podfile).
|
|
291
|
+
for (const root of this.#reachedNativePackageRoots()) roots.add(root)
|
|
292
|
+
// RN CORE isn't a `dependencies` entry (config reports only reactNativePath) and autolinking
|
|
293
|
+
// never lists it, but its native source (React/, ReactCommon/, ReactAndroid/, sdks/, ...) is a
|
|
294
|
+
// build input like any other dep's -- walk it in full, the same way.
|
|
295
|
+
const rnPath = config.reactNativePath
|
|
296
|
+
if (typeof rnPath === 'string' && isAbsolute(rnPath)) roots.add(rnPath)
|
|
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
|
|
345
302
|
}
|
|
346
|
-
this.#
|
|
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
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
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
|
+
#captureNativeFile(full) {
|
|
317
|
+
if (this.#seen.has(full)) return
|
|
318
|
+
let source
|
|
319
|
+
try {
|
|
320
|
+
source = realReadFileSync(full)
|
|
321
|
+
} catch {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
this.#seen.add(full)
|
|
325
|
+
const format = classifyFormat(full, { content: source })
|
|
326
|
+
this.#recordCapture(full, source, { format, resource: format === undefined })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// node_modules roots the graph reached that also ship native code (podspec or ios/android dir):
|
|
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).
|
|
332
|
+
#reachedNativePackageRoots() {
|
|
333
|
+
const pkgRoots = new Set()
|
|
334
|
+
for (const abs of this.#seen) {
|
|
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))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#looksNative(root) {
|
|
343
|
+
if (existsSync(join(root, 'ios')) || existsSync(join(root, 'android'))) return true
|
|
344
|
+
return this.#hasPodspec(root)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// True if any *.podspec exists under `dir`, pruning the same subtrees the capture walk skips.
|
|
348
|
+
#hasPodspec(dir) {
|
|
349
|
+
let entries
|
|
350
|
+
try {
|
|
351
|
+
entries = realReaddirSync(dir, { withFileTypes: true })
|
|
352
|
+
} catch {
|
|
353
|
+
return false
|
|
354
|
+
}
|
|
355
|
+
for (const ent of entries) {
|
|
356
|
+
if (ent.isSymbolicLink()) continue
|
|
357
|
+
if (ent.isFile()) {
|
|
358
|
+
if (isPodspec(ent.name)) return true
|
|
359
|
+
} else if (ent.isDirectory() && !isSkippedNativeWalkDir(ent.name)) {
|
|
360
|
+
if (this.#hasPodspec(join(dir, ent.name))) return true
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Recursively attest native build-input files under `dir`. Genuine readers (plugin bookkeeping,
|
|
367
|
+
// not re-recorded by --fs); symlinks not followed (cycle/escape hazard).
|
|
368
|
+
#captureNativeTree(dir, atRoot = false) {
|
|
369
|
+
let entries
|
|
370
|
+
try {
|
|
371
|
+
entries = realReaddirSync(dir, { withFileTypes: true })
|
|
372
|
+
} catch {
|
|
373
|
+
return // dir missing / not a directory -- nothing to walk
|
|
374
|
+
}
|
|
375
|
+
for (const ent of entries) {
|
|
376
|
+
if (ent.isSymbolicLink()) continue
|
|
377
|
+
const full = join(dir, ent.name)
|
|
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
|
|
383
|
+
}
|
|
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' })
|
|
347
397
|
}
|
|
348
398
|
}
|
|
349
399
|
|
|
350
400
|
#captureModules(modules, entries) {
|
|
351
401
|
for (const module of modules) {
|
|
352
|
-
//
|
|
353
|
-
// same path and is what preModules carry too, so we read it off the module either way.
|
|
402
|
+
// module.path is the absolute path (both graph modules and preModules carry it).
|
|
354
403
|
const modPath = module?.path
|
|
355
|
-
// Skip synthetic
|
|
356
|
-
// require.context's `?ctx=` modules and __prelude__): a path that isn't an existing
|
|
357
|
-
// absolute file has no bytes to hash. Mirrors webpack's `isAbsolute && existsSync`
|
|
358
|
-
// guard and esbuild's `namespace === 'file'` gate.
|
|
404
|
+
// Skip synthetic/virtual modules (require.context's ?ctx=, __prelude__): no on-disk bytes to hash.
|
|
359
405
|
if (typeof modPath !== 'string' || !isAbsolute(modPath) || !existsSync(modPath)) continue
|
|
360
406
|
|
|
361
407
|
const kind = classifyExtension(modPath, this.#resources)
|
|
@@ -367,12 +413,8 @@ export class StasisMetro {
|
|
|
367
413
|
}
|
|
368
414
|
const url = pathToFileURL(modPath).toString()
|
|
369
415
|
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
// 'react-native') and the resolved target is `dep.absolutePath`. We only attest edges
|
|
373
|
-
// whose TARGET is code: resource imports (an asset pulled in for its bytes) are captured
|
|
374
|
-
// as files below but carry no import edge, matching webpack/esbuild (`kind === 'code'`).
|
|
375
|
-
// Edges to virtual/unresolved targets are skipped like the modules above.
|
|
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).
|
|
376
418
|
for (const dep of module.dependencies?.values?.() ?? []) {
|
|
377
419
|
const target = dep?.absolutePath
|
|
378
420
|
const specifier = dep?.data?.name
|
|
@@ -385,36 +427,56 @@ export class StasisMetro {
|
|
|
385
427
|
if (this.#seen.has(modPath)) continue
|
|
386
428
|
this.#seen.add(modPath)
|
|
387
429
|
|
|
388
|
-
// Read
|
|
389
|
-
//
|
|
390
|
-
// Use the REAL reader (state-util.js), like webpack/esbuild: this capture read is the
|
|
391
|
-
// plugin's own bookkeeping, NOT a program fs read. `import { readFileSync } from
|
|
392
|
-
// 'node:fs'` (the named binding above, before this fix) is rebound to the patched
|
|
393
|
-
// reader by `stasis run --fs`'s syncBuiltinESMExports(), so reading through it would
|
|
394
|
-
// re-record the whole Metro module graph into the preload/main bundle -- it belongs to
|
|
395
|
-
// THIS plugin's sidecar instead.
|
|
430
|
+
// Read from disk, not module.getSource() -- addFile re-reads disk and asserts byte-equality.
|
|
431
|
+
// Real reader (state-util.js): a --fs-patched readFileSync would re-record the graph into the preload.
|
|
396
432
|
const source = realReadFileSync(modPath)
|
|
397
|
-
// Code-classified files must be UTF-8
|
|
398
|
-
// malformed input; refuse it rather than silently encoding it as a resource. Resource-
|
|
399
|
-
// classified files pass through as-is -- State derives 'resource' vs 'resource:base64'.
|
|
433
|
+
// Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
|
|
400
434
|
if (kind === 'code') {
|
|
401
435
|
assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
|
|
402
436
|
}
|
|
403
|
-
this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', reason: '
|
|
437
|
+
this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', reason: 'metro' })
|
|
404
438
|
}
|
|
405
439
|
}
|
|
406
440
|
}
|
|
407
441
|
|
|
408
|
-
// Idiomatic Metro-config wrapper
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
// rather than the experimental hook. Pure (returns a new object; doesn't mutate `config`).
|
|
442
|
+
// Idiomatic Metro-config wrapper: returns a new config with `serializer.customSerializer` wired
|
|
443
|
+
// so stasis captures the graph + preModules while your existing serializer (or Metro's default)
|
|
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`.
|
|
413
446
|
export function withStasis(config = {}, options = {}) {
|
|
414
|
-
const stasis = new StasisMetro(options)
|
|
447
|
+
const stasis = new StasisMetro(options, { ownsConfig: true })
|
|
415
448
|
const existing = config.serializer?.customSerializer ?? undefined
|
|
449
|
+
// Metro's transform cache is a CORRECTNESS hazard here, not a speed knob: it decides which modules
|
|
450
|
+
// Metro itself LOADS. On a cache hit Metro returns the stored result without calling a worker, so
|
|
451
|
+
// the worker-side toolchain is never loaded in any process and never reaches the root -- exactly
|
|
452
|
+
// what the constructor's --child-process assert exists to guarantee. The default store is a
|
|
453
|
+
// FileStore in the OS tmpdir shared with every other Metro run, so ONE earlier `metro build`/dev
|
|
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.
|
|
470
|
+
const stores = config.cacheStores
|
|
471
|
+
if (dropCache && (typeof stores === 'function' || stores?.length > 0)) {
|
|
472
|
+
console.warn(
|
|
473
|
+
'[stasis] StasisMetro: ignoring the `cacheStores` in your Metro config for this build -- a cached ' +
|
|
474
|
+
'transform skips the worker, leaving the toolchain it loads unattested'
|
|
475
|
+
)
|
|
476
|
+
}
|
|
416
477
|
return {
|
|
417
478
|
...config,
|
|
479
|
+
...(dropCache && { cacheStores: [] }),
|
|
418
480
|
serializer: {
|
|
419
481
|
...config.serializer,
|
|
420
482
|
customSerializer: stasis.customSerializer(existing),
|