@exodus/stasis-plugins 1.0.0-beta.1 → 1.0.0-beta.11
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 +208 -118
- package/src/metro-transformer.js +32 -71
- package/src/metro.js +343 -175
- package/src/plugins.js +51 -86
- package/src/rollup.js +327 -0
- package/src/webpack.js +84 -290
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,17 +21,66 @@ function entrySet(graph) {
|
|
|
26
21
|
return new Set()
|
|
27
22
|
}
|
|
28
23
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
26
|
+
const AUTO_INCLUDES = [
|
|
27
|
+
'metro-runtime/src/modules/asyncRequire.js',
|
|
28
|
+
'@react-native-community/cli/setup_env.sh',
|
|
29
|
+
]
|
|
30
|
+
|
|
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.
|
|
40
84
|
let metroDefault
|
|
41
85
|
function metroDefaultSerializer() {
|
|
42
86
|
if (metroDefault) return metroDefault
|
|
@@ -57,126 +101,88 @@ function metroDefaultSerializer() {
|
|
|
57
101
|
)
|
|
58
102
|
}
|
|
59
103
|
}
|
|
60
|
-
//
|
|
61
|
-
// either as the namespace itself or under `.default`.
|
|
104
|
+
// Metro ships CJS; require exposes the fn as the namespace or under `.default`.
|
|
62
105
|
baseJSBundle = baseJSBundle.default ?? baseJSBundle
|
|
63
106
|
bundleToString = bundleToString.default ?? bundleToString
|
|
64
|
-
// Return the `.code` string (bundleToString yields { code, metadata }); a string is a
|
|
65
|
-
// valid customSerializer return and Metro regenerates the source map itself.
|
|
66
107
|
metroDefault = (entryPoint, preModules, graph, options) =>
|
|
67
108
|
bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
|
|
68
109
|
return metroDefault
|
|
69
110
|
}
|
|
70
111
|
|
|
71
|
-
// Stasis plugin for
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// THIS PLUGIN IS THE CAPTURE HALF -- LOAD LIVES IN A SEPARATE TRANSFORMER:
|
|
84
|
-
// The other plugins serve bundle bytes back into the build because their bundler
|
|
85
|
-
// reads + transforms files in the same process the bundle lives in (webpack's
|
|
86
|
-
// inputFileSystem wrapper, esbuild's onLoad). Metro reads + transforms in workers,
|
|
87
|
-
// before serialization, so the serializer can't inject bytes into the transform.
|
|
88
|
-
// Load is instead handled per-worker by the companion `./metro-transformer.js`
|
|
89
|
-
// (wired as Metro's `transformer.transformerPath`): unlike capture, load only ever
|
|
90
|
-
// READS the immutable bundle and every worker reads the same bytes, so there is no
|
|
91
|
-
// cross-worker state to merge. This serializer therefore REJECTS bundle=load.
|
|
92
|
-
// Capture modes (lock/bundle add|replace|frozen, and lock=none/ignore) all work.
|
|
93
|
-
//
|
|
94
|
-
// USAGE (metro.config.js) -- prefer withStasis:
|
|
95
|
-
// const { withStasis } = require('@exodus/stasis/metro')
|
|
96
|
-
// module.exports = withStasis(require('./metro.config.base'), { /* scope, lock, ... */ })
|
|
97
|
-
//
|
|
98
|
-
// Then CAPTURE with child-process forwarding ON -- Metro transforms in worker processes, so
|
|
99
|
-
// `--child-process` is REQUIRED on a capture run to attest the toolchain those workers load
|
|
100
|
-
// (babel.config.js, @babel/core, the RN preset + plugins). The plugin throws at config time if
|
|
101
|
-
// you forget it on a writing run: `stasis run --lock=add --child-process -- <metro build cmd>`.
|
|
102
|
-
// A later `--lock=frozen` verify needs NO flag (verification is per-process, independent of the
|
|
103
|
-
// shard channel). withStasis wires the STABLE `serializer.customSerializer`, which is the only
|
|
104
|
-
// Metro surface that receives `preModules` (the polyfills/runtime Metro prepends to every
|
|
105
|
-
// bundle -- real shipped files). It wraps an existing customSerializer if you have one,
|
|
106
|
-
// otherwise reproduces Metro's default output. For manual wiring use
|
|
107
|
-
// `new StasisMetro(opts).customSerializer(base?)`. `serializerHook` (the experimental
|
|
108
|
-
// hook) is a lower-coverage fallback -- see its note.
|
|
109
|
-
//
|
|
110
|
-
// KNOWN LIMITATIONS (Metro-specific coverage gaps -- documented, not silently ignored):
|
|
111
|
-
// - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer
|
|
112
|
-
// per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe
|
|
113
|
-
// would otherwise keep attesting a file's first-build bytes after an edit changed them.
|
|
114
|
-
// Metro gives the serializer no watch signal on the first build, so the first rebuild is the
|
|
115
|
-
// earliest refusal point. Load mode (the companion worker transformer) is unaffected and
|
|
116
|
-
// runs fine under a dev server -- it only reads immutable attested bytes.
|
|
117
|
-
// - Child-process capture is best-effort within that one shot. A worker KILLED by signal
|
|
118
|
-
// before its exit hook (jest-worker forceExit / pool overflow) loses its shard SILENTLY at
|
|
119
|
-
// capture time -- a later frozen run still catches the gap (fail-closed), but nothing warns
|
|
120
|
-
// during capture. And it relies on the loader's exit-time write, which standalone (no stasis
|
|
121
|
-
// loader) Metro never reaches -- it mints no shard dir and produces no toolchain-complete
|
|
122
|
-
// lockfile. Use a one-shot `metro build` under `stasis run --child-process` to capture, then
|
|
123
|
-
// verify with `--lock=frozen`.
|
|
124
|
-
// - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
|
|
125
|
-
// by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
|
|
126
|
-
// scales/files arrays), NOT as separate graph modules, so only the base file is attested
|
|
127
|
-
// here. Assets pulled in by the native build via `react-native.config.js` (e.g. fonts
|
|
128
|
-
// through `react-native-asset`) aren't in Metro's JS graph at all and aren't covered.
|
|
129
|
-
// - Platform-specific lockfiles: edges are recorded under the as-written specifier
|
|
130
|
-
// ('./Foo') resolved to the platform variant Metro picked ('Foo.ios.js'), all under the
|
|
131
|
-
// '*' conditions key. A lockfile captured for one platform won't match a frozen run for
|
|
132
|
-
// another -- capture/verify per platform. It fails CLOSED (rejects, never silently
|
|
133
|
-
// 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).
|
|
134
123
|
export class StasisMetro {
|
|
135
124
|
#seen = new Set()
|
|
136
125
|
#state
|
|
137
126
|
#resources
|
|
138
127
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
128
|
+
// Base dir for resolution; snapshotted at construction so a later chdir can't skew capture.
|
|
129
|
+
#projectDir = process.cwd()
|
|
130
|
+
|
|
131
|
+
// Whether this instance already captured; the dev-server rebuild guard in #run trips on the second.
|
|
142
132
|
#ran = false
|
|
143
133
|
|
|
144
|
-
|
|
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 } = {}) {
|
|
145
140
|
const { state } = resolvePluginState('StasisMetro', options, process.cwd())
|
|
146
|
-
this.#state = state // null when the plugin should be inert (Rule 7)
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// unattested and a later frozen/load run rejects it. Fail loudly at config time rather than
|
|
152
|
-
// silently emit an incomplete lockfile. Gate on the SAME predicate as the shard channel itself
|
|
153
|
-
// (writeLockfile || writeBundle, i.e. lock/bundle add|replace) so the flag is demanded exactly
|
|
154
|
-
// when forwarding does something: a frozen/ignore/none run verifies per-process and needs no
|
|
155
|
-
// channel (the CLI frozen tests pass without the flag), and load mode reads the bundle via the
|
|
156
|
-
// 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).
|
|
157
146
|
if (state && (state.config.writeLockfile || state.config.writeBundle)) {
|
|
158
147
|
assert.ok(
|
|
159
148
|
state.config.childProcess,
|
|
160
149
|
'StasisMetro: child-process capture must be enabled -- Metro transforms in worker processes, ' +
|
|
161
|
-
|
|
150
|
+
`and without it the toolchain they load (${WORKER_TOOLCHAIN}) ` +
|
|
162
151
|
'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
|
|
163
152
|
'or "childProcess": true in stasis.config.json.'
|
|
164
153
|
)
|
|
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.
|
|
158
|
+
const channel = State.preload ?? state
|
|
159
|
+
if (channel.config.writeLockfile || channel.config.writeBundle) {
|
|
160
|
+
process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1'
|
|
161
|
+
}
|
|
165
162
|
}
|
|
166
|
-
//
|
|
167
|
-
// 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.
|
|
168
164
|
this.#resources = state?.config.resources ?? new Set()
|
|
169
165
|
}
|
|
170
166
|
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
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.
|
|
176
179
|
customSerializer(baseSerializer = undefined) {
|
|
177
180
|
if (baseSerializer !== undefined && typeof baseSerializer !== 'function') {
|
|
178
181
|
throw new TypeError('StasisMetro.customSerializer(base?): base must be a function or omitted')
|
|
179
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
|
|
180
186
|
return (entryPoint, preModules, graph, options) => {
|
|
181
187
|
this.#run(graph, preModules)
|
|
182
188
|
const serialize = baseSerializer ?? metroDefaultSerializer()
|
|
@@ -184,72 +190,218 @@ export class StasisMetro {
|
|
|
184
190
|
}
|
|
185
191
|
}
|
|
186
192
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// `customSerializer`. LOWER COVERAGE: the hook's signature is `(graph, delta)`; Metro never
|
|
190
|
-
// passes `preModules` to it, so the prepended polyfills/runtime go UNATTESTED. Prefer
|
|
191
|
-
// 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.
|
|
192
195
|
serializerHook = (graph, _delta) => {
|
|
193
196
|
this.#run(graph)
|
|
194
197
|
}
|
|
195
198
|
|
|
196
199
|
#run(graph, preModules) {
|
|
197
200
|
if (!this.#state) return
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
)
|
|
205
|
-
}
|
|
206
|
-
// Watch/dev-server capture is EXPLICITLY UNSUPPORTED -- refuse the second serialization
|
|
207
|
-
// loudly before it captures anything. Capture's dedupe is keyed by PATH, not content
|
|
208
|
-
// (#seen below, plus State.write's compare-and-skip caches), so on a rebuild where a
|
|
209
|
-
// file's bytes changed the plugin would skip re-capture: the emitted bundle would use the
|
|
210
|
-
// new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones. The
|
|
211
|
-
// serializer fires exactly once per one-shot `metro build`; only a dev server
|
|
212
|
-
// (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on
|
|
213
|
-
// the FIRST build, so the first rebuild is the earliest point capture can refuse. (Load
|
|
214
|
-
// mode has no such hazard and legitimately runs under a dev server -- it lives in the
|
|
215
|
-
// companion worker transformer, rejected above, which only READS immutable attested bytes.)
|
|
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.
|
|
203
|
+
if (this.#state.config.loadBundle) return
|
|
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).
|
|
216
207
|
if (this.#ran) {
|
|
217
208
|
throw new Error(
|
|
218
209
|
'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
|
|
219
210
|
)
|
|
220
211
|
}
|
|
221
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
|
+
}
|
|
222
223
|
this.#capture(graph, preModules)
|
|
223
224
|
|
|
224
|
-
// The
|
|
225
|
-
//
|
|
226
|
-
// serializer only runs once Metro has successfully built + transformed the whole graph
|
|
227
|
-
// (a failed build throws before serialization), so this is the clean-build equivalent of
|
|
228
|
-
// webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate -- and it runs at
|
|
229
|
-
// most once per instance (the rebuild guard above), so this write is the one-shot build's
|
|
230
|
-
// 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.
|
|
231
227
|
if (this.#state !== State.preload) this.#state.write()
|
|
232
228
|
}
|
|
233
229
|
|
|
234
230
|
#capture(graph, preModules) {
|
|
235
231
|
const entries = entrySet(graph)
|
|
236
|
-
// preModules first (Metro's prepended polyfills/runtime
|
|
237
|
-
//
|
|
238
|
-
// synthetic ones like __prelude__ are skipped by the existsSync guard below). Then the
|
|
239
|
-
// 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.
|
|
240
234
|
if (preModules) this.#captureModules(preModules, entries)
|
|
241
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.
|
|
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' })
|
|
249
|
+
}
|
|
250
|
+
|
|
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.
|
|
253
|
+
#captureAutoIncludes() {
|
|
254
|
+
for (const specifier of AUTO_INCLUDES) {
|
|
255
|
+
let modPath
|
|
256
|
+
try {
|
|
257
|
+
modPath = require.resolve(specifier, { paths: [this.#projectDir] })
|
|
258
|
+
} catch {
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
if (this.#seen.has(modPath)) continue
|
|
262
|
+
try {
|
|
263
|
+
this.#state.relative(modPath)
|
|
264
|
+
} catch {
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
this.#seen.add(modPath)
|
|
268
|
+
// Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
|
|
269
|
+
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
|
+
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
|
|
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
|
+
}
|
|
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' })
|
|
397
|
+
}
|
|
242
398
|
}
|
|
243
399
|
|
|
244
400
|
#captureModules(modules, entries) {
|
|
245
401
|
for (const module of modules) {
|
|
246
|
-
//
|
|
247
|
-
// 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).
|
|
248
403
|
const modPath = module?.path
|
|
249
|
-
// Skip synthetic
|
|
250
|
-
// require.context's `?ctx=` modules and __prelude__): a path that isn't an existing
|
|
251
|
-
// absolute file has no bytes to hash. Mirrors webpack's `isAbsolute && existsSync`
|
|
252
|
-
// guard and esbuild's `namespace === 'file'` gate.
|
|
404
|
+
// Skip synthetic/virtual modules (require.context's ?ctx=, __prelude__): no on-disk bytes to hash.
|
|
253
405
|
if (typeof modPath !== 'string' || !isAbsolute(modPath) || !existsSync(modPath)) continue
|
|
254
406
|
|
|
255
407
|
const kind = classifyExtension(modPath, this.#resources)
|
|
@@ -261,12 +413,8 @@ export class StasisMetro {
|
|
|
261
413
|
}
|
|
262
414
|
const url = pathToFileURL(modPath).toString()
|
|
263
415
|
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
// 'react-native') and the resolved target is `dep.absolutePath`. We only attest edges
|
|
267
|
-
// whose TARGET is code: resource imports (an asset pulled in for its bytes) are captured
|
|
268
|
-
// as files below but carry no import edge, matching webpack/esbuild (`kind === 'code'`).
|
|
269
|
-
// 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).
|
|
270
418
|
for (const dep of module.dependencies?.values?.() ?? []) {
|
|
271
419
|
const target = dep?.absolutePath
|
|
272
420
|
const specifier = dep?.data?.name
|
|
@@ -279,36 +427,56 @@ export class StasisMetro {
|
|
|
279
427
|
if (this.#seen.has(modPath)) continue
|
|
280
428
|
this.#seen.add(modPath)
|
|
281
429
|
|
|
282
|
-
// Read
|
|
283
|
-
//
|
|
284
|
-
// Use the REAL reader (state-util.js), like webpack/esbuild: this capture read is the
|
|
285
|
-
// plugin's own bookkeeping, NOT a program fs read. `import { readFileSync } from
|
|
286
|
-
// 'node:fs'` (the named binding above, before this fix) is rebound to the patched
|
|
287
|
-
// reader by `stasis run --fs`'s syncBuiltinESMExports(), so reading through it would
|
|
288
|
-
// re-record the whole Metro module graph into the preload/main bundle -- it belongs to
|
|
289
|
-
// 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.
|
|
290
432
|
const source = realReadFileSync(modPath)
|
|
291
|
-
// Code-classified files must be UTF-8
|
|
292
|
-
// malformed input; refuse it rather than silently encoding it as a resource. Resource-
|
|
293
|
-
// 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.
|
|
294
434
|
if (kind === 'code') {
|
|
295
435
|
assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
|
|
296
436
|
}
|
|
297
|
-
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' })
|
|
298
438
|
}
|
|
299
439
|
}
|
|
300
440
|
}
|
|
301
441
|
|
|
302
|
-
// Idiomatic Metro-config wrapper
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
// 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`.
|
|
307
446
|
export function withStasis(config = {}, options = {}) {
|
|
308
|
-
const stasis = new StasisMetro(options)
|
|
447
|
+
const stasis = new StasisMetro(options, { ownsConfig: true })
|
|
309
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
|
+
}
|
|
310
477
|
return {
|
|
311
478
|
...config,
|
|
479
|
+
...(dropCache && { cacheStores: [] }),
|
|
312
480
|
serializer: {
|
|
313
481
|
...config.serializer,
|
|
314
482
|
customSerializer: stasis.customSerializer(existing),
|