@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/LICENSE +21 -0
- package/README.md +9 -0
- package/package.json +36 -0
- package/src/esbuild.js +224 -0
- package/src/metro-transformer.js +125 -0
- package/src/metro.js +290 -0
- package/src/plugins.js +204 -0
- package/src/webpack.js +547 -0
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
|
+
}
|