@exodus/stasis-plugins 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/metro.js ADDED
@@ -0,0 +1,290 @@
1
+ import assert from 'node:assert/strict'
2
+ import { isUtf8 } from 'node:buffer'
3
+ import { existsSync } from 'node:fs'
4
+ import { createRequire } from 'node:module'
5
+ import { isAbsolute } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+
8
+ import { resolvePluginState } from './plugins.js'
9
+ import { State } from '@exodus/stasis-core/state'
10
+ import { realReadFileSync } from '@exodus/stasis-core/state-util'
11
+ import { classifyExtension } from '@exodus/stasis-core/util'
12
+
13
+ const require = createRequire(import.meta.url)
14
+
15
+ // `graph.entryPoints` is a `Set<string>` of absolute entry paths in current Metro (it was
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.
22
+ function entrySet(graph) {
23
+ const ep = graph?.entryPoints
24
+ if (ep instanceof Set) return ep
25
+ if (Array.isArray(ep)) return new Set(ep)
26
+ return new Set()
27
+ }
28
+
29
+ // Metro has no public "default serializer" export, but its default bundle CODE is exactly
30
+ // `bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code` (Metro's own
31
+ // Server.js else-branch when no customSerializer is set). bundleToString returns
32
+ // `{ code, metadata }` -- we return the `code` string; a plain string is a valid
33
+ // customSerializer return and Metro regenerates the source map itself, the same way its
34
+ // else-branch does. These internal module paths are the ecosystem-standard entry (Expo,
35
+ // metro-serializer-*): `metro/src/*` resolves through ~0.82, `metro/private/*` exists from
36
+ // 0.81.4 on (and is the ONLY option from 0.83), so the two-tier require below spans the
37
+ // range. Loaded lazily and ONLY when withStasis()/customSerializer() must produce output
38
+ // with no user-supplied base, so a normal capture build never touches Metro internals --
39
+ // and the `metro` spec is built indirectly so it isn't treated as a hard dependency of stasis.
40
+ let metroDefault
41
+ function metroDefaultSerializer() {
42
+ if (metroDefault) return metroDefault
43
+ const M = 'metro'
44
+ let baseJSBundle, bundleToString
45
+ try {
46
+ baseJSBundle = require(`${M}/src/DeltaBundler/Serializers/baseJSBundle`)
47
+ bundleToString = require(`${M}/src/lib/bundleToString`)
48
+ } catch {
49
+ try {
50
+ baseJSBundle = require(`${M}/private/DeltaBundler/Serializers/baseJSBundle`)
51
+ bundleToString = require(`${M}/private/lib/bundleToString`)
52
+ } catch (cause) {
53
+ throw new Error(
54
+ "StasisMetro: couldn't load Metro's default serializer (tried metro/src and " +
55
+ 'metro/private). Pass your existing customSerializer to withStasis()/customSerializer() instead.',
56
+ { cause }
57
+ )
58
+ }
59
+ }
60
+ // Interop: Metro ships CJS; `require` of its Flow-compiled modules exposes the function
61
+ // either as the namespace itself or under `.default`.
62
+ baseJSBundle = baseJSBundle.default ?? baseJSBundle
63
+ 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
+ metroDefault = (entryPoint, preModules, graph, options) =>
67
+ bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code
68
+ return metroDefault
69
+ }
70
+
71
+ // Stasis plugin for the Metro bundler (React Native).
72
+ //
73
+ // WHY THIS HOOKS THE SERIALIZER, NOT A TRANSFORMER:
74
+ // Metro transforms each file in a pool of WORKER processes. A stasis State is
75
+ // process-local (its observed sources/imports/formats live in in-memory Maps
76
+ // flushed by State.write on the main process), so a transformer-side capture
77
+ // would scatter observations across workers that never reach the writer.
78
+ // Metro's serializer runs once, in the MAIN process, AFTER the whole graph is
79
+ // built, with the complete module graph in hand -- the one place a single State
80
+ // can see every module and resolution edge (mirrors webpack's afterResolve and
81
+ // esbuild's onResolve/onLoad, which likewise run in the bundler's main process).
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
+ // - Child-process capture is best-effort and one-shot-build only. A worker KILLED by signal
112
+ // before its exit hook (jest-worker forceExit / pool overflow) loses its shard SILENTLY at
113
+ // capture time -- a later frozen run still catches the gap (fail-closed), but nothing warns
114
+ // during capture. And it relies on the loader's exit-time write: a dev server (`metro start`,
115
+ // pool kept warm, killed by signal) never gets there, and standalone (no stasis loader) Metro
116
+ // mints no shard dir -- neither produces a toolchain-complete lockfile. Use a one-shot
117
+ // `metro build` under `stasis run --child-process` to capture, then verify with `--lock=frozen`.
118
+ // - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
119
+ // by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
120
+ // scales/files arrays), NOT as separate graph modules, so only the base file is attested
121
+ // here. Assets pulled in by the native build via `react-native.config.js` (e.g. fonts
122
+ // through `react-native-asset`) aren't in Metro's JS graph at all and aren't covered.
123
+ // - Platform-specific lockfiles: edges are recorded under the as-written specifier
124
+ // ('./Foo') resolved to the platform variant Metro picked ('Foo.ios.js'), all under the
125
+ // '*' conditions key. A lockfile captured for one platform won't match a frozen run for
126
+ // another -- capture/verify per platform. It fails CLOSED (rejects, never silently
127
+ // accepts a mismatched resolution).
128
+ export class StasisMetro {
129
+ #seen = new Set()
130
+ #state
131
+ #resources
132
+
133
+ constructor(options = {}) {
134
+ const { state } = resolvePluginState('StasisMetro', options, process.cwd())
135
+ this.#state = state // null when the plugin should be inert (Rule 7)
136
+ // A CAPTURE-THAT-WRITES requires child-process forwarding: Metro transforms every file in a
137
+ // pool of worker processes (see the class note), and the toolchain those workers load --
138
+ // babel.config.js, @babel/core, the React Native preset + its plugins -- is observed only
139
+ // there. Without --child-process those workers can't report back, so that toolchain goes
140
+ // unattested and a later frozen/load run rejects it. Fail loudly at config time rather than
141
+ // silently emit an incomplete lockfile. Gate on the SAME predicate as the shard channel itself
142
+ // (writeLockfile || writeBundle, i.e. lock/bundle add|replace) so the flag is demanded exactly
143
+ // when forwarding does something: a frozen/ignore/none run verifies per-process and needs no
144
+ // channel (the CLI frozen tests pass without the flag), and load mode reads the bundle via the
145
+ // companion transformer -- none of those should be forced to pass --child-process.
146
+ if (state && (state.config.writeLockfile || state.config.writeBundle)) {
147
+ assert.ok(
148
+ state.config.childProcess,
149
+ 'StasisMetro: child-process capture must be enabled -- Metro transforms in worker processes, ' +
150
+ 'and without it the toolchain they load (babel.config.js, @babel/core, the RN preset + plugins) ' +
151
+ 'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
152
+ 'or "childProcess": true in stasis.config.json.'
153
+ )
154
+ }
155
+ // resources is a Config field now (validated + coordinated against any preload in
156
+ // resolvePluginState); cache the resolved Set for the per-file classify hot path.
157
+ this.#resources = state?.config.resources ?? new Set()
158
+ }
159
+
160
+ // Build a Metro `serializer.customSerializer`: captures the graph AND `preModules`
161
+ // (Metro's prepended polyfills/runtime -- real shipped files that the experimental hook
162
+ // never sees), writes state, then produces output. With a `baseSerializer` we delegate to
163
+ // it; with none we reproduce Metro's default output. This is the stable, full-coverage
164
+ // surface (withStasis wires it for you).
165
+ customSerializer(baseSerializer = undefined) {
166
+ if (baseSerializer !== undefined && typeof baseSerializer !== 'function') {
167
+ throw new TypeError('StasisMetro.customSerializer(base?): base must be a function or omitted')
168
+ }
169
+ return (entryPoint, preModules, graph, options) => {
170
+ this.#run(graph, preModules)
171
+ const serialize = baseSerializer ?? metroDefaultSerializer()
172
+ return serialize(entryPoint, preModules, graph, options)
173
+ }
174
+ }
175
+
176
+ // Wire into `serializer.experimentalSerializerHook`. Observation-only: Metro ignores the
177
+ // return value, so stasis never produces output -- convenient when you can't touch
178
+ // `customSerializer`. LOWER COVERAGE: the hook's signature is `(graph, delta)`; Metro never
179
+ // passes `preModules` to it, so the prepended polyfills/runtime go UNATTESTED. Prefer
180
+ // withStasis/customSerializer. Bound so it can be passed by reference.
181
+ serializerHook = (graph, _delta) => {
182
+ this.#run(graph)
183
+ }
184
+
185
+ #run(graph, preModules) {
186
+ if (!this.#state) return
187
+ if (this.#state.config.loadBundle) {
188
+ throw new Error(
189
+ 'StasisMetro: bundle=load is served by the companion worker transformer ' +
190
+ "(transformer.transformerPath = '@exodus/stasis/metro-transformer'), not this serializer " +
191
+ 'plugin -- Metro transforms in workers before serialization. Remove this plugin from your ' +
192
+ 'serializer config in load mode, or use bundle=add/replace/frozen here to capture.'
193
+ )
194
+ }
195
+ this.#capture(graph, preModules)
196
+
197
+ // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
198
+ // Standalone and sidecar States have no such hook, so the plugin writes them here. The
199
+ // serializer only runs once Metro has successfully built + transformed the whole graph
200
+ // (a failed build throws before serialization), so this is the clean-build equivalent of
201
+ // webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate. In watch / dev
202
+ // mode it fires on every rebuild; State.write's per-artifact compare-and-skip makes the
203
+ // unchanged-graph case a cheap no-op.
204
+ if (this.#state !== State.preload) this.#state.write()
205
+ }
206
+
207
+ #capture(graph, preModules) {
208
+ const entries = entrySet(graph)
209
+ // preModules first (Metro's prepended polyfills/runtime + InitializeCore -- real on-disk
210
+ // files that ship in every bundle; only the customSerializer path receives them, and the
211
+ // synthetic ones like __prelude__ are skipped by the existsSync guard below). Then the
212
+ // app module graph.
213
+ if (preModules) this.#captureModules(preModules, entries)
214
+ this.#captureModules(graph.dependencies.values(), entries)
215
+ }
216
+
217
+ #captureModules(modules, entries) {
218
+ for (const module of modules) {
219
+ // Metro keys `graph.dependencies` by the module's absolute path; `module.path` is that
220
+ // same path and is what preModules carry too, so we read it off the module either way.
221
+ const modPath = module?.path
222
+ // Skip synthetic / virtual modules (Metro injects some with non-disk paths, e.g.
223
+ // require.context's `?ctx=` modules and __prelude__): a path that isn't an existing
224
+ // absolute file has no bytes to hash. Mirrors webpack's `isAbsolute && existsSync`
225
+ // guard and esbuild's `namespace === 'file'` gate.
226
+ if (typeof modPath !== 'string' || !isAbsolute(modPath) || !existsSync(modPath)) continue
227
+
228
+ const kind = classifyExtension(modPath, this.#resources)
229
+ if (kind === 'unknown') {
230
+ throw new Error(
231
+ `StasisMetro: unsupported extension for '${modPath}' -- ` +
232
+ `add its extension or filename to the plugin's resources option or stop importing it`
233
+ )
234
+ }
235
+ const url = pathToFileURL(modPath).toString()
236
+
237
+ // Record this module's outgoing resolution edges. Metro keys a module's `dependencies`
238
+ // by an opaque hash; the as-written specifier is `dep.data.name` (e.g. './hello' or
239
+ // 'react-native') and the resolved target is `dep.absolutePath`. We only attest edges
240
+ // whose TARGET is code: resource imports (an asset pulled in for its bytes) are captured
241
+ // as files below but carry no import edge, matching webpack/esbuild (`kind === 'code'`).
242
+ // Edges to virtual/unresolved targets are skipped like the modules above.
243
+ for (const dep of module.dependencies?.values?.() ?? []) {
244
+ const target = dep?.absolutePath
245
+ const specifier = dep?.data?.name
246
+ if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
247
+ if (typeof specifier !== 'string') continue
248
+ if (classifyExtension(target, this.#resources) !== 'code') continue
249
+ this.#state.addImport(url, specifier, pathToFileURL(target).toString())
250
+ }
251
+
252
+ if (this.#seen.has(modPath)) continue
253
+ this.#seen.add(modPath)
254
+
255
+ // Read the bytes from disk rather than `module.getSource()` -- addFile re-reads disk
256
+ // and asserts byte-equality against what we pass, and disk is the thing stasis attests.
257
+ // Use the REAL reader (state-util.js), like webpack/esbuild: this capture read is the
258
+ // plugin's own bookkeeping, NOT a program fs read. `import { readFileSync } from
259
+ // 'node:fs'` (the named binding above, before this fix) is rebound to the patched
260
+ // reader by `stasis run --fs`'s syncBuiltinESMExports(), so reading through it would
261
+ // re-record the whole Metro module graph into the preload/main bundle -- it belongs to
262
+ // THIS plugin's sidecar instead.
263
+ const source = realReadFileSync(modPath)
264
+ // Code-classified files must be UTF-8. A code extension carrying non-UTF-8 bytes is
265
+ // malformed input; refuse it rather than silently encoding it as a resource. Resource-
266
+ // classified files pass through as-is -- State derives 'resource' vs 'resource:base64'.
267
+ if (kind === 'code') {
268
+ assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
269
+ }
270
+ this.#state.addFile(url, { source, isEntry: entries.has(modPath), resource: kind === 'resource', reason: 'StasisMetro' })
271
+ }
272
+ }
273
+ }
274
+
275
+ // Idiomatic Metro-config wrapper (cf. withNativeWind / withSentryConfig): returns a new
276
+ // config with `serializer.customSerializer` wired so stasis captures the graph + preModules
277
+ // and your existing customSerializer (or Metro's default) still produces the bundle. This is
278
+ // the recommended surface -- it uses the stable customSerializer (so preModules are covered)
279
+ // rather than the experimental hook. Pure (returns a new object; doesn't mutate `config`).
280
+ export function withStasis(config = {}, options = {}) {
281
+ const stasis = new StasisMetro(options)
282
+ const existing = config.serializer?.customSerializer ?? undefined
283
+ return {
284
+ ...config,
285
+ serializer: {
286
+ ...config.serializer,
287
+ customSerializer: stasis.customSerializer(existing),
288
+ },
289
+ }
290
+ }
package/src/plugins.js ADDED
@@ -0,0 +1,204 @@
1
+ // Plugin <-> preload coordination (resolvePluginState): decides which State a bundler
2
+ // plugin drives, given its options and the ambient preload. The per-file classification
3
+ // helpers (pathExt / parseResourcesOption / classifyExtension) live in util.js -- they're
4
+ // pure and shared with State's `--fs` capture, so both paths classify against one source
5
+ // of truth. Kept out of state.js because the coordination only consults State.preload +
6
+ // Config helpers, never State internals.
7
+
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { DEFAULT_LOCK, assertOptionsMatchConfig, validatePluginOptions } from '@exodus/stasis-core/config'
11
+ import { ensureStateForLoader, isLoaderInstalled } from '@exodus/stasis-core/hooks'
12
+ import { State } from '@exodus/stasis-core/state'
13
+ import { extSetsEqual, parseResourcesOption } from '@exodus/stasis-core/util'
14
+
15
+ // Decides which State a bundler plugin (StasisWebpack / StasisEsbuild) should run against,
16
+ // given its constructor options and the ambient preload (or lack thereof). Returns either
17
+ // { state: null, isNoop: true } -- plugin should register no hooks and become inert -- or
18
+ // { state, isNoop: false } -- plugin should drive the returned State.
19
+ //
20
+ // The lockfile is always unified: a plugin never specifies its own lockfile position
21
+ // (validatePluginOptions rejects `lockFile`). When an ambient preload exists the plugin
22
+ // MUST share it -- it either reuses the preload State outright or runs as a sidecar that
23
+ // shares the preload's hashes/entries/modules + lockfile data. Only the BUNDLE may split
24
+ // off per-plugin (via bundleFile, a sidecar). A process that genuinely needs a non-default
25
+ // lockfile location sets it process-wide (Config / EXODUS_STASIS_LOCK_FILE), not per-plugin.
26
+ //
27
+ // Rules:
28
+ // 1. Plugin asking for any lockfile mode other than 'none' / 'ignore' requires a preload.
29
+ // Without one it's a hard throw -- the lockfile would otherwise only cover the bundled
30
+ // app and silently miss every dependency the bundler itself pulled in.
31
+ // 2. With a preload, the plugin's lock mode must agree with the preload's.
32
+ // 3. Lockfile is unified: when plugin and preload align we reuse the preload State so a
33
+ // single lockfile is written.
34
+ // 4. Bundle may split: with a preload that already has bundle on, the plugin cannot disable
35
+ // bundle and the bundle mode must match. The plugin's bundleFile, however, may differ
36
+ // -- in which case we construct a sidecar State sharing the preload's lockfile data.
37
+ // 5. Same bundleFile (or unspecified) reuses the preload State.
38
+ // 6. Different bundleFile creates a sidecar.
39
+ // 7. Plugin with lock='none' AND bundle='none' and no preload is a no-op (lets framework
40
+ // plugins be env-controlled off without throwing on the lock=none/bundle=none invariant).
41
+ export function resolvePluginState(label, options, cwd) {
42
+ validatePluginOptions(label, options)
43
+ const { scope, lock, bundle, bundleFile, resourcesBundleFile, debug, childProcess, resources } = options
44
+ // Same-instance fast path: the plugin's stasis-core IS the binary's, the
45
+ // hooks are installed, but the lazy initState hasn't fired yet (plugin
46
+ // ran AHEAD of the first user-file load -- webpack constructs plugins
47
+ // during config-parse before user code executes). Force-init now so the
48
+ // plugin lands in the ambient branch and inherits the loader's Config.
49
+ // We deliberately do NOT do this when the loader is a SEPARATE module
50
+ // instance (different stasis-core copy in the plugin's node_modules vs
51
+ // the binary's): force-init there would create a phantom State.preload
52
+ // with its own beforeExit/exit save handlers that race the real loader's
53
+ // lockfile write. Cross-instance detection lives below via env vars.
54
+ if (!State.preload && isLoaderInstalled()) ensureStateForLoader()
55
+ const ambient = State.preload
56
+
57
+ if (!ambient) {
58
+ if (lock === 'none' && bundle === 'none') return { state: null, isNoop: true } // Rule 7
59
+ // Rule 7 needs BOTH to be 'none'. A partial opt-out -- one field is 'none' but
60
+ // the other was simply omitted -- would otherwise fall through to either a
61
+ // misleading Rule 1 throw about the default lockfile mode, or to Config's
62
+ // generic "stasis needs a lockfile or a bundle" RangeError. Catch the partial
63
+ // case explicitly. Only fires when the OTHER field is undefined: an active
64
+ // bundle mode like `bundle:'load'` / `'frozen'` / `'add'` paired with
65
+ // `lock:'none'` is a legitimate "bundle as trust root" config that Config
66
+ // accepts (see config.js's bundle=load / bundle=frozen rules).
67
+ if ((lock === 'none' && bundle === undefined) || (bundle === 'none' && lock === undefined)) {
68
+ throw new Error(
69
+ `${label}: to opt out of stasis without a preload, pass BOTH lock='none' and bundle='none' ` +
70
+ `(got lock=${lock === undefined ? `default '${DEFAULT_LOCK}'` : `'${lock}'`}, ` +
71
+ `bundle=${bundle === undefined ? 'default' : `'${bundle}'`})`
72
+ )
73
+ }
74
+ // Cross-instance "running under stasis run" detection: `stasis run`'s
75
+ // bin script always sets EXODUS_STASIS_LOCK on the spawned child (even
76
+ // for the default `--lock=add`), so the env var is a reliable
77
+ // process-wide signal regardless of which stasis-core copy is hosting
78
+ // this code. When set, the runtime's loader is providing full lockfile
79
+ // coverage for the process; the plugin's lockfile (at its own path,
80
+ // typically a sidecar like `sources.stasis.lock.json`) is a sibling
81
+ // artifact covering bundler-side resolutions, and the "lock=add needs
82
+ // a preload" rationale doesn't apply. Skip that rule under stasis run;
83
+ // Config below will pick up env-var defaults (lock/scope/bundle) for
84
+ // the standalone plugin State.
85
+ const underStasisRun = process.env.EXODUS_STASIS_LOCK !== undefined
86
+ const effectiveLock = lock ?? DEFAULT_LOCK
87
+ if (!underStasisRun && effectiveLock !== 'none' && effectiveLock !== 'ignore') {
88
+ const phrasing = lock === undefined
89
+ ? `the default lockfile mode ('${DEFAULT_LOCK}')`
90
+ : `lockfile mode '${lock}'`
91
+ throw new Error(
92
+ `${label}: ${phrasing} requires a stasis preload; ` +
93
+ `pass lock='none' or lock='ignore' to opt out, or run under the stasis loader`
94
+ )
95
+ }
96
+ return { state: new State(cwd, options), isNoop: false }
97
+ }
98
+
99
+ // Preload is active. Since `lockFile` is not a plugin option, the lockfile is
100
+ // necessarily the ambient's -- the plugin shares it (reuse or sidecar below).
101
+ // Enforce coordination on the remaining fields before deciding which.
102
+ const pc = ambient.config
103
+ if (lock !== undefined && lock !== pc.lock) {
104
+ throw new Error(`${label}: lock='${lock}' conflicts with active preload lock='${pc.lock}'`)
105
+ }
106
+ // scope/debug/childProcess must agree with the preload on every preload-coupled path -- reuse
107
+ // adopts preload's config wholesale, and sidecar copies it field by field. Catch a
108
+ // disagreement here rather than silently overriding the user's value.
109
+ if (scope !== undefined && scope !== pc.scope) {
110
+ throw new Error(`${label}: scope='${scope}' conflicts with active preload scope='${pc.scope}'`)
111
+ }
112
+ if (debug !== undefined && debug !== pc.debug) {
113
+ throw new Error(`${label}: debug=${debug} conflicts with active preload debug=${pc.debug}`)
114
+ }
115
+ if (childProcess !== undefined && childProcess !== pc.childProcess) {
116
+ throw new Error(`${label}: childProcess=${childProcess} conflicts with active preload childProcess=${pc.childProcess}`)
117
+ }
118
+ // resources is process-wide (a Config field), so a plugin's allowlist must agree with
119
+ // the preload's -- reuse adopts it wholesale, a sidecar inherits it. Compare the parsed
120
+ // sets (order/dupes/case don't matter), failing on a real disagreement rather than
121
+ // letting one plugin silently widen the attested asset set.
122
+ if (resources !== undefined) {
123
+ const parsed = parseResourcesOption(label, resources)
124
+ if (!extSetsEqual(parsed, pc.resources)) {
125
+ throw new Error(
126
+ `${label}: resources=[${[...parsed].join(', ')}] conflicts with active preload resources=[${[...pc.resources].join(', ')}]`
127
+ )
128
+ }
129
+ }
130
+
131
+ // A plugin instance is attaching to the preload now (its constructor called us). Mark it so the
132
+ // preload's bundle `reason` map can tell apart files run merely fs-READS during the build (the
133
+ // plugin's modules, e.g. webpack+babel reading app source through --fs) from genuine run
134
+ // dependencies recorded before any plugin existed. See State#bundleReason.
135
+ ambient.markPluginAttached()
136
+
137
+ if (pc.bundle) {
138
+ // Preload has bundle on -- plugin can't disable, must match mode.
139
+ if (bundle === 'none' || bundle === 'ignore') {
140
+ throw new Error(
141
+ `${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`
142
+ )
143
+ }
144
+ if (bundle !== undefined && bundle !== pc.bundleMode) {
145
+ throw new Error(
146
+ `${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`
147
+ )
148
+ }
149
+ if (bundleFile === undefined || bundleFile === pc.bundleFile) {
150
+ // Rule 5: same bundleFile (or unspecified) reuses the preload.
151
+ assertOptionsMatchConfig(pc, options)
152
+ return { state: ambient, isNoop: false }
153
+ }
154
+ // Rule 6: different path -- sidecar inherits preload's modes, overrides bundleFile
155
+ // (and resourcesBundleFile if the plugin provided one for a split layout).
156
+ const sidecar = new State(cwd, {
157
+ parent: ambient,
158
+ scope: pc.scope,
159
+ lock: pc.lock,
160
+ bundle: pc.bundleMode,
161
+ bundleFile,
162
+ resourcesBundleFile,
163
+ debug: pc.debug,
164
+ childProcess: pc.childProcess,
165
+ resources: [...pc.resources],
166
+ })
167
+ return { state: sidecar, isNoop: false }
168
+ }
169
+
170
+ // Preload exists but has no bundle.
171
+ if (bundle === undefined || bundle === 'none' || bundle === 'ignore') {
172
+ // Plugin doesn't want a bundle either: just reuse preload for lockfile coverage.
173
+ // bundle='ignore' here legitimately disagrees with preload's bundleMode ('none'),
174
+ // both mean "no bundle written by the plugin", so skip the bundle field in the
175
+ // cross-check rather than letting assertOptionsMatchConfig flag the difference.
176
+ // Defense-in-depth: the strip is only safe when the preload's bundleMode is
177
+ // actually in the no-bundle equivalence class. If a future bundleMode reads as
178
+ // `!pc.bundle` but means something else, the strip would silently mask a real
179
+ // disagreement -- the assert pins the contract to today's two values.
180
+ assert.ok(pc.bundleMode === 'none' || pc.bundleMode === 'ignore',
181
+ `unexpected preload bundleMode='${pc.bundleMode}' in the no-bundle branch`)
182
+ const { bundle: _b, ...rest } = options
183
+ assertOptionsMatchConfig(pc, rest)
184
+ return { state: ambient, isNoop: false }
185
+ }
186
+ // Plugin wants its own bundle alongside preload's lockfile -- sidecar.
187
+ if (!bundleFile) {
188
+ throw new Error(
189
+ `${label}: bundle='${bundle}' alongside a preload without bundle requires an explicit bundleFile`
190
+ )
191
+ }
192
+ const sidecar = new State(cwd, {
193
+ parent: ambient,
194
+ scope: pc.scope,
195
+ lock: pc.lock,
196
+ bundle,
197
+ bundleFile,
198
+ resourcesBundleFile,
199
+ debug: pc.debug,
200
+ childProcess: pc.childProcess,
201
+ resources: [...pc.resources],
202
+ })
203
+ return { state: sidecar, isNoop: false }
204
+ }