@exodus/stasis-plugins 1.0.0-beta.0 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/esbuild.js +30 -1
- package/src/metro-transformer.js +7 -6
- package/src/metro.js +153 -20
- package/src/plugins.js +3 -0
- package/src/webpack.js +36 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis-plugins",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
4
|
"description": "Bundler plugins (webpack, esbuild, Metro) for @exodus/stasis-core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
},
|
|
32
32
|
"homepage": "https://github.com/ExodusOSS/stasis#readme",
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@exodus/stasis-core": "1.0.0-beta.
|
|
34
|
+
"@exodus/stasis-core": "1.0.0-beta.2"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/esbuild.js
CHANGED
|
@@ -21,6 +21,11 @@ export class StasisEsbuild {
|
|
|
21
21
|
#resources
|
|
22
22
|
#loaders
|
|
23
23
|
|
|
24
|
+
// Build starts observed by this (capture-mode) instance, across rebuilds AND across separate
|
|
25
|
+
// esbuild.build()/context() calls sharing one instance. The second start is refused -- see
|
|
26
|
+
// the onStart hook in setup().
|
|
27
|
+
#captureBuildCount = 0
|
|
28
|
+
|
|
24
29
|
constructor(options = {}, { loaders } = {}) {
|
|
25
30
|
// A caller that owns a State (e.g. `stasis build`) can pass it directly to drive the plugin, bypassing resolvePluginState; mode follows that State's config, and a foreign-copy State fails closed via the instanceof miss.
|
|
26
31
|
const state = options instanceof State
|
|
@@ -49,9 +54,33 @@ export class StasisEsbuild {
|
|
|
49
54
|
return 'stasis'
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
setup = ({ onResolve, onLoad, onEnd, resolve }) => {
|
|
57
|
+
setup = ({ onResolve, onLoad, onStart, onEnd, resolve }) => {
|
|
53
58
|
if (!this.#state) return // noop plugin
|
|
54
59
|
|
|
60
|
+
// Watch/rebuild capture is EXPLICITLY UNSUPPORTED -- fail the second build loudly before it
|
|
61
|
+
// captures anything. Capture's dedupe is keyed by PATH, not content (#seen below, plus
|
|
62
|
+
// State.write's compare-and-skip caches), so on a rebuild where a file's bytes changed the
|
|
63
|
+
// plugin would skip re-capture: the emitted output would use the new bytes while the
|
|
64
|
+
// bundle/lockfile silently kept attesting the OLD ones. Rather than track content changes
|
|
65
|
+
// across rebuilds, refuse them outright. esbuild rebuilds (watch mode, context.rebuild())
|
|
66
|
+
// re-fire onStart/onEnd against the SAME setup registrations, so an instance-level counter
|
|
67
|
+
// sees every start; it also covers reusing one plugin instance across two esbuild.build()
|
|
68
|
+
// calls, which has the same staleness hazard. Returning errors from onStart is the
|
|
69
|
+
// esbuild-idiomatic hard failure (the build reports them and onEnd's clean-build gate then
|
|
70
|
+
// skips the write). Load mode registers nothing here: it serves immutable attested bytes,
|
|
71
|
+
// so rebuilds have no drift hazard.
|
|
72
|
+
if (!this.#state.config.loadBundle) {
|
|
73
|
+
onStart(() => {
|
|
74
|
+
this.#captureBuildCount += 1
|
|
75
|
+
if (this.#captureBuildCount === 1) return undefined
|
|
76
|
+
return {
|
|
77
|
+
errors: [{
|
|
78
|
+
text: 'StasisEsbuild: watch/rebuild is not supported for capture -- run a one-shot build (rebuilds would silently attest stale content)',
|
|
79
|
+
}],
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
// The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
|
|
56
85
|
// Standalone and sidecar States have no such hook, so the plugin writes them when the
|
|
57
86
|
// build finishes -- but only on a clean build, otherwise a partial bundle/lockfile
|
package/src/metro-transformer.js
CHANGED
|
@@ -14,15 +14,16 @@ import { State } from '@exodus/stasis-core/state'
|
|
|
14
14
|
// there is nothing to synchronize between them. That makes the per-worker transformer
|
|
15
15
|
// the natural seam for load -- each worker independently serves attested bytes, no IPC.
|
|
16
16
|
//
|
|
17
|
-
// HOW IT WIRES IN (metro.config.js,
|
|
17
|
+
// HOW IT WIRES IN (metro.config.js) -- wire it PERMANENTLY, alongside the serializer half:
|
|
18
18
|
// module.exports = {
|
|
19
|
-
//
|
|
20
|
-
// transformerPath: require.resolve('@exodus/stasis/metro-transformer'),
|
|
21
|
-
// },
|
|
19
|
+
// transformerPath: require.resolve('@exodus/stasis/metro-transformer'), // top-level, a sibling of `transformer`
|
|
22
20
|
// }
|
|
23
21
|
// // run with EXODUS_STASIS_BUNDLE=load (+ EXODUS_STASIS_BUNDLE_FILE / LOCK / SCOPE),
|
|
24
|
-
// // OR a stasis.config.json with "bundle":"load".
|
|
25
|
-
// //
|
|
22
|
+
// // OR a stasis.config.json with "bundle":"load". Outside load mode this transformer is
|
|
23
|
+
// // a transparent pass-through (see getLoadState), and under load the StasisMetro
|
|
24
|
+
// // serializer is one too -- one committed metro.config.js carries both halves, the
|
|
25
|
+
// // mode picks the active one (the config is itself attested at capture, so a load
|
|
26
|
+
// // run must execute it unedited).
|
|
26
27
|
//
|
|
27
28
|
// Metro calls `transformerPath`'s `transform(config, projectRoot, filename, data,
|
|
28
29
|
// options)` in each worker with `data` = the file's on-disk bytes (a Buffer) and
|
package/src/metro.js
CHANGED
|
@@ -26,6 +26,26 @@ function entrySet(graph) {
|
|
|
26
26
|
return new Set()
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Well-known files a React Native / Metro build REQUIRES that the serializer's module
|
|
30
|
+
// graph never reliably carries -- attested automatically at capture when they resolve
|
|
31
|
+
// from the project, and skipped when they don't (not every Metro project ships them):
|
|
32
|
+
// - metro-runtime/src/modules/asyncRequire.js: Metro's default
|
|
33
|
+
// `transformer.asyncRequireModulePath`. The transform injects it to implement
|
|
34
|
+
// dynamic import(), so it ships in bundles without necessarily appearing as a
|
|
35
|
+
// module of the graph handed to the serializer.
|
|
36
|
+
// - @react-native-community/cli/setup_env.sh: sourced by the React Native CLI's
|
|
37
|
+
// native build phases (Xcode / Gradle) around the JS bundle step -- build-shaping
|
|
38
|
+
// toolchain that is never part of the JS module graph at all.
|
|
39
|
+
// A `resource: true` entry bypasses the `resources` allowlist: this is a stasis-vetted
|
|
40
|
+
// list of SPECIFIC files, not a user-widened extension class. Code entries ride the
|
|
41
|
+
// normal loader-format inference. Extend the list here, not via an option -- an option
|
|
42
|
+
// would make the attested set configuration-dependent, which is exactly what
|
|
43
|
+
// auto-include exists to avoid.
|
|
44
|
+
const AUTO_INCLUDES = [
|
|
45
|
+
{ specifier: 'metro-runtime/src/modules/asyncRequire.js', resource: false },
|
|
46
|
+
{ specifier: '@react-native-community/cli/setup_env.sh', resource: true },
|
|
47
|
+
]
|
|
48
|
+
|
|
29
49
|
// Metro has no public "default serializer" export, but its default bundle CODE is exactly
|
|
30
50
|
// `bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code` (Metro's own
|
|
31
51
|
// Server.js else-branch when no customSerializer is set). bundleToString returns
|
|
@@ -86,14 +106,22 @@ function metroDefaultSerializer() {
|
|
|
86
106
|
// inputFileSystem wrapper, esbuild's onLoad). Metro reads + transforms in workers,
|
|
87
107
|
// before serialization, so the serializer can't inject bytes into the transform.
|
|
88
108
|
// Load is instead handled per-worker by the companion `./metro-transformer.js`
|
|
89
|
-
// (wired as Metro's `
|
|
109
|
+
// (wired as Metro's top-level `transformerPath`): unlike capture, load only ever
|
|
90
110
|
// READS the immutable bundle and every worker reads the same bytes, so there is no
|
|
91
|
-
// cross-worker state to merge.
|
|
92
|
-
//
|
|
111
|
+
// cross-worker state to merge. Under bundle=load this serializer is therefore a
|
|
112
|
+
// transparent PASS-THROUGH (captures nothing, writes nothing, delegates to the
|
|
113
|
+
// base serializer), mirroring the transformer, which passes through in every
|
|
114
|
+
// non-load mode. Wire BOTH halves permanently; the mode picks the active one.
|
|
115
|
+
// That matters because metro.config.js is itself attested at capture, so a load
|
|
116
|
+
// run must execute it unedited. Capture modes (lock/bundle add|replace|frozen,
|
|
117
|
+
// and lock=none/ignore) all capture here.
|
|
93
118
|
//
|
|
94
|
-
// USAGE (metro.config.js) -- prefer withStasis:
|
|
119
|
+
// USAGE (metro.config.js) -- prefer withStasis; wire the load half permanently alongside it:
|
|
95
120
|
// const { withStasis } = require('@exodus/stasis/metro')
|
|
96
|
-
// module.exports = withStasis(
|
|
121
|
+
// module.exports = withStasis({
|
|
122
|
+
// ...require('./metro.config.base'),
|
|
123
|
+
// transformerPath: require.resolve('@exodus/stasis/metro-transformer'), // the load half
|
|
124
|
+
// }, { /* scope, lock, ... */ })
|
|
97
125
|
//
|
|
98
126
|
// Then CAPTURE with child-process forwarding ON -- Metro transforms in worker processes, so
|
|
99
127
|
// `--child-process` is REQUIRED on a capture run to attest the toolchain those workers load
|
|
@@ -107,14 +135,38 @@ function metroDefaultSerializer() {
|
|
|
107
135
|
// `new StasisMetro(opts).customSerializer(base?)`. `serializerHook` (the experimental
|
|
108
136
|
// hook) is a lower-coverage fallback -- see its note.
|
|
109
137
|
//
|
|
138
|
+
// AUTO-INCLUDED FILES: capture also attests the fixed AUTO_INCLUDES list (see it above
|
|
139
|
+
// the class) -- files a React Native build requires that the serializer's graph never
|
|
140
|
+
// reliably carries (Metro's asyncRequire module, the RN CLI's setup_env.sh). Each is
|
|
141
|
+
// attested when it resolves from the project and skipped when it doesn't, through the
|
|
142
|
+
// same addFile path as graph modules, so frozen verification covers them fail-closed.
|
|
143
|
+
// NOTE: a lockfile captured before this list existed doesn't attest these files, so a
|
|
144
|
+
// frozen run against it fails once they're present -- recapture (lock=replace) once.
|
|
145
|
+
//
|
|
110
146
|
// KNOWN LIMITATIONS (Metro-specific coverage gaps -- documented, not silently ignored):
|
|
111
|
-
// -
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
147
|
+
// - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer
|
|
148
|
+
// per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe
|
|
149
|
+
// would otherwise keep attesting a file's first-build bytes after an edit changed them.
|
|
150
|
+
// Metro gives the serializer no watch signal on the first build, so the first rebuild is the
|
|
151
|
+
// earliest refusal point. Load mode is unaffected and runs fine under a dev server --
|
|
152
|
+
// the serializer passes through and the worker transformer only reads immutable
|
|
153
|
+
// attested bytes.
|
|
154
|
+
// - Child-process capture is best-effort within that one shot. A writing plugin opts the
|
|
155
|
+
// build's workers into the loader's SIGTERM shard flush (EXODUS_STASIS_SHARD_SIGNAL_FLUSH,
|
|
156
|
+
// set in the constructor), so a worker force-killed by jest-worker's forceExit -- SIGTERM
|
|
157
|
+
// 500ms after END, routine for a transform worker whose event loop doesn't drain --
|
|
158
|
+
// contributes its shard best-effort: the flush must complete inside forceExit's follow-up
|
|
159
|
+
// 500ms SIGTERM->SIGKILL window. A worker killed any OTHER way still loses its shard
|
|
160
|
+
// SILENTLY at capture time -- SIGKILL (jest-worker's memory-limit killChild, a
|
|
161
|
+
// killSignal:'SIGKILL' override), a non-SIGTERM signal (group SIGINT/SIGHUP, another
|
|
162
|
+
// killSignal override; the flush handler is SIGTERM-only), or a nested orchestration's
|
|
163
|
+
// Metro MAIN process (itself a capturing child, it snapshots its config before this
|
|
164
|
+
// constructor sets the flag, so only its descendants are covered). A later frozen run
|
|
165
|
+
// catches every such gap (fail-closed), but nothing warns during capture. And it relies
|
|
166
|
+
// on the loader's exit/signal-time writes, which standalone (no stasis loader) Metro
|
|
167
|
+
// never reaches -- it mints no shard dir and produces no toolchain-complete lockfile.
|
|
168
|
+
// Use a one-shot `metro build` under `stasis run --child-process` to capture, then
|
|
169
|
+
// verify with `--lock=frozen`.
|
|
118
170
|
// - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
|
|
119
171
|
// by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
|
|
120
172
|
// scales/files arrays), NOT as separate graph modules, so only the base file is attested
|
|
@@ -130,6 +182,18 @@ export class StasisMetro {
|
|
|
130
182
|
#state
|
|
131
183
|
#resources
|
|
132
184
|
|
|
185
|
+
// Base dir AUTO_INCLUDES resolution starts from: the directory Metro runs in -- the
|
|
186
|
+
// same cwd resolvePluginState anchors the State at, and an in-or-under-root path by
|
|
187
|
+
// State's own root detection (which walks UP from it), so Node's node_modules walk-up
|
|
188
|
+
// from here also covers hoisted workspace layouts. Snapshotted at construction so a
|
|
189
|
+
// later chdir can't skew what a capture attests.
|
|
190
|
+
#projectDir = process.cwd()
|
|
191
|
+
|
|
192
|
+
// Whether this instance already served one capture (#run). The serializer fires once per
|
|
193
|
+
// one-shot `metro build`; a dev server re-invokes it per rebuild, which capture refuses --
|
|
194
|
+
// see the guard in #run.
|
|
195
|
+
#ran = false
|
|
196
|
+
|
|
133
197
|
constructor(options = {}) {
|
|
134
198
|
const { state } = resolvePluginState('StasisMetro', options, process.cwd())
|
|
135
199
|
this.#state = state // null when the plugin should be inert (Rule 7)
|
|
@@ -151,6 +215,26 @@ export class StasisMetro {
|
|
|
151
215
|
'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
|
|
152
216
|
'or "childProcess": true in stasis.config.json.'
|
|
153
217
|
)
|
|
218
|
+
// Opt this build's children into the loader's SIGTERM shard flush: jest-worker's
|
|
219
|
+
// forceExit would otherwise silently drop a non-draining worker's shard (mechanics in
|
|
220
|
+
// the KNOWN LIMITATIONS bullet above; the handler lives in hooks.js). The flag rides
|
|
221
|
+
// process.env, so EVERY capturing child forked after config time inherits it --
|
|
222
|
+
// Metro's transform workers are the intended audience, but a codegen fork the build
|
|
223
|
+
// spawns rides along; the loader's own gates (child + shard forwarding) bound what it
|
|
224
|
+
// can do there to a flush-then-redeliver on SIGTERM. The plugin can't act inside the
|
|
225
|
+
// workers (only the loader runs there), so an env opt-in is the seam; scoped HERE,
|
|
226
|
+
// not globally, because changing a child's default-kill disposition is otherwise the
|
|
227
|
+
// user's domain. Two deliberate softenings:
|
|
228
|
+
// - keyed on the PRELOAD where one exists: the shard channel is minted by, and the
|
|
229
|
+
// children's capture mode follows, the preload's config -- a Rule-6 sidecar's own
|
|
230
|
+
// bundle mode says nothing about either, and setting the flag under a frozen
|
|
231
|
+
// preload would claim an opt-in that can never engage;
|
|
232
|
+
// - `||=`, not `=`: an explicit ambient opt-out ('0'/'false', envBool's disable
|
|
233
|
+
// values) is the user's call and is honored; only unset/empty is claimed.
|
|
234
|
+
const channel = State.preload ?? state
|
|
235
|
+
if (channel.config.writeLockfile || channel.config.writeBundle) {
|
|
236
|
+
process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1'
|
|
237
|
+
}
|
|
154
238
|
}
|
|
155
239
|
// resources is a Config field now (validated + coordinated against any preload in
|
|
156
240
|
// resolvePluginState); cache the resolved Set for the per-file classify hot path.
|
|
@@ -184,23 +268,36 @@ export class StasisMetro {
|
|
|
184
268
|
|
|
185
269
|
#run(graph, preModules) {
|
|
186
270
|
if (!this.#state) return
|
|
187
|
-
|
|
271
|
+
// bundle=load: pass through -- load is served by the companion worker transformer, and
|
|
272
|
+
// metro.config.js is itself attested at capture, so the same wiring must run unedited
|
|
273
|
+
// in load mode (see the class note). Checked BEFORE the one-shot guard: load captures
|
|
274
|
+
// nothing and legitimately re-serializes per rebuild under a dev server.
|
|
275
|
+
if (this.#state.config.loadBundle) return
|
|
276
|
+
// Watch/dev-server capture is EXPLICITLY UNSUPPORTED -- refuse the second serialization
|
|
277
|
+
// loudly before it captures anything. Capture's dedupe is keyed by PATH, not content
|
|
278
|
+
// (#seen below, plus State.write's compare-and-skip caches), so on a rebuild where a
|
|
279
|
+
// file's bytes changed the plugin would skip re-capture: the emitted bundle would use the
|
|
280
|
+
// new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones. The
|
|
281
|
+
// serializer fires exactly once per one-shot `metro build`; only a dev server
|
|
282
|
+
// (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on
|
|
283
|
+
// the FIRST build, so the first rebuild is the earliest point capture can refuse. (Load
|
|
284
|
+
// mode has no such hazard and legitimately runs under a dev server -- it passed through
|
|
285
|
+
// above, and the companion worker transformer only READS immutable attested bytes.)
|
|
286
|
+
if (this.#ran) {
|
|
188
287
|
throw new Error(
|
|
189
|
-
'StasisMetro:
|
|
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.'
|
|
288
|
+
'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
|
|
193
289
|
)
|
|
194
290
|
}
|
|
291
|
+
this.#ran = true
|
|
195
292
|
this.#capture(graph, preModules)
|
|
196
293
|
|
|
197
294
|
// The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
|
|
198
295
|
// Standalone and sidecar States have no such hook, so the plugin writes them here. The
|
|
199
296
|
// serializer only runs once Metro has successfully built + transformed the whole graph
|
|
200
297
|
// (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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
298
|
+
// webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate -- and it runs at
|
|
299
|
+
// most once per instance (the rebuild guard above), so this write is the one-shot build's
|
|
300
|
+
// single flush.
|
|
204
301
|
if (this.#state !== State.preload) this.#state.write()
|
|
205
302
|
}
|
|
206
303
|
|
|
@@ -212,6 +309,42 @@ export class StasisMetro {
|
|
|
212
309
|
// app module graph.
|
|
213
310
|
if (preModules) this.#captureModules(preModules, entries)
|
|
214
311
|
this.#captureModules(graph.dependencies.values(), entries)
|
|
312
|
+
// AFTER the graph so #seen already holds every graph module: an auto-include that
|
|
313
|
+
// did land in the graph this build (asyncRequire does when the app uses dynamic
|
|
314
|
+
// import()) is deduped instead of re-recorded.
|
|
315
|
+
this.#captureAutoIncludes()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Attest the AUTO_INCLUDES list (see its note above). Skips, per entry: a specifier
|
|
319
|
+
// that doesn't resolve from the project (the file genuinely doesn't exist there --
|
|
320
|
+
// nothing to attest), a file already captured as a graph module this build, and a
|
|
321
|
+
// resolution landing outside the project root (unattestable -- lockfile/bundle keys
|
|
322
|
+
// are root-relative; same out-of-scope stance as the load transformer's inScope).
|
|
323
|
+
// Everything that IS attested goes through the exact addFile path graph modules use,
|
|
324
|
+
// so frozen runs verify these files fail-closed like any other capture.
|
|
325
|
+
#captureAutoIncludes() {
|
|
326
|
+
for (const { specifier, resource } of AUTO_INCLUDES) {
|
|
327
|
+
let modPath
|
|
328
|
+
try {
|
|
329
|
+
modPath = require.resolve(specifier, { paths: [this.#projectDir] })
|
|
330
|
+
} catch {
|
|
331
|
+
continue // not installed in this project -- nothing to attest
|
|
332
|
+
}
|
|
333
|
+
if (this.#seen.has(modPath)) continue
|
|
334
|
+
try {
|
|
335
|
+
this.#state.relative(modPath)
|
|
336
|
+
} catch {
|
|
337
|
+
continue // outside the project root -- unattestable
|
|
338
|
+
}
|
|
339
|
+
this.#seen.add(modPath)
|
|
340
|
+
// Real reader for the same reason as #captureModules: this is plugin bookkeeping,
|
|
341
|
+
// not a program fs read -- it must not be re-recorded by a --fs patched reader.
|
|
342
|
+
const source = realReadFileSync(modPath)
|
|
343
|
+
if (!resource) {
|
|
344
|
+
assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
|
|
345
|
+
}
|
|
346
|
+
this.#state.addFile(pathToFileURL(modPath).toString(), { source, resource, reason: 'StasisMetro' })
|
|
347
|
+
}
|
|
215
348
|
}
|
|
216
349
|
|
|
217
350
|
#captureModules(modules, entries) {
|
package/src/plugins.js
CHANGED
|
@@ -163,6 +163,8 @@ export function resolvePluginState(label, options, cwd) {
|
|
|
163
163
|
debug: pc.debug,
|
|
164
164
|
childProcess: pc.childProcess,
|
|
165
165
|
resources: [...pc.resources],
|
|
166
|
+
// carried over: sidecars skip stasis.config.json discovery, which may have set it
|
|
167
|
+
brotliQuality: pc.brotliQuality,
|
|
166
168
|
})
|
|
167
169
|
return { state: sidecar, isNoop: false }
|
|
168
170
|
}
|
|
@@ -199,6 +201,7 @@ export function resolvePluginState(label, options, cwd) {
|
|
|
199
201
|
debug: pc.debug,
|
|
200
202
|
childProcess: pc.childProcess,
|
|
201
203
|
resources: [...pc.resources],
|
|
204
|
+
brotliQuality: pc.brotliQuality, // carried over, like the Rule 6 sidecar above
|
|
202
205
|
})
|
|
203
206
|
return { state: sidecar, isNoop: false }
|
|
204
207
|
}
|
package/src/webpack.js
CHANGED
|
@@ -484,35 +484,45 @@ export class StasisWebpack {
|
|
|
484
484
|
})
|
|
485
485
|
})
|
|
486
486
|
|
|
487
|
+
// Watch-mode capture is EXPLICITLY UNSUPPORTED -- refuse it loudly before anything is
|
|
488
|
+
// captured. Capture's dedupe is keyed by PATH, not content (#seen above, plus State.write's
|
|
489
|
+
// compare-and-skip caches), so on a watch rebuild where a file's bytes changed the plugin
|
|
490
|
+
// would skip re-capture: the emitted output would use the new bytes while the bundle /
|
|
491
|
+
// lockfile silently kept attesting the OLD ones. Rather than track content changes across
|
|
492
|
+
// rebuilds, refuse watch outright. compiler.hooks.watchRun fires ONLY under
|
|
493
|
+
// compiler.watch() (both webpack 4 and 5) and BEFORE the first watch build's compilation,
|
|
494
|
+
// so this rejects the whole watch session up front, not just the second build. watchRun is
|
|
495
|
+
// an AsyncSeriesHook: a sync `.tap` callback that throws propagates through the hook's call
|
|
496
|
+
// chain and fails the watcher -- exactly the loud refusal we want, so keep it a plain tap.
|
|
497
|
+
// Load mode is deliberately untouched (see #applyLoadMode): it serves immutable attested
|
|
498
|
+
// bytes back into the build, so a dev-server rebuild has no drift hazard there.
|
|
499
|
+
compiler.hooks.watchRun.tap('Stasis', () => {
|
|
500
|
+
throw new Error(
|
|
501
|
+
'StasisWebpack: watch mode is not supported for capture -- ' +
|
|
502
|
+
'run a one-shot build (watch rebuilds would silently attest stale content)'
|
|
503
|
+
)
|
|
504
|
+
})
|
|
505
|
+
|
|
487
506
|
// The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
|
|
488
507
|
// Standalone and sidecar States have no such hook, so the plugin writes them when the
|
|
489
508
|
// compiler finishes -- but only on a clean build. A failed compilation has incomplete
|
|
490
509
|
// state, and lock=replace would otherwise overwrite a good lockfile with a partial one.
|
|
491
510
|
if (this.#state !== State.preload) {
|
|
492
|
-
// Watch-mode detection. compiler.hooks.watchRun fires ONLY under compiler.watch() (both
|
|
493
|
-
// webpack 4 and 5) and always before that build's `done`, so the flag is set in time.
|
|
494
|
-
// The flag is per-compiler (this closure), which is what we want: each compiler reports
|
|
495
|
-
// its own run kind even when one shared plugin instance drives several.
|
|
496
|
-
let watching = false
|
|
497
|
-
compiler.hooks.watchRun.tap('Stasis', () => { watching = true })
|
|
498
511
|
compiler.hooks.done.tap('Stasis', (stats) => {
|
|
499
512
|
if (stats.hasErrors()) return
|
|
500
|
-
// Write immediately
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// time compiler.run()'s callback resolves).
|
|
507
|
-
// Defer ONLY the multi-compiler one-shot case. compiler.hooks.done fires once PER CHILD
|
|
508
|
-
// COMPILER, so a MultiCompiler with N targets sharing this one State's growing bundle
|
|
509
|
-
// would otherwise pay N brotli + writeFileSync passes over an ever-larger payload (the
|
|
513
|
+
// Write immediately for a single compiler: only one `done`, so there is no N-write
|
|
514
|
+
// amplification and no reason to give up the old write-by-`done` durability (the
|
|
515
|
+
// bundle is on disk by the time compiler.run()'s callback resolves). Defer ONLY the
|
|
516
|
+
// multi-compiler one-shot case. compiler.hooks.done fires once PER CHILD COMPILER, so
|
|
517
|
+
// a MultiCompiler with N targets sharing this one State's growing bundle would
|
|
518
|
+
// otherwise pay N brotli + writeFileSync passes over an ever-larger payload (the
|
|
510
519
|
// dominant, super-linear cost). Deferral collapses those into ONE write of the final
|
|
511
|
-
// bundle at process exit -- mirroring how the preload writes (hooks.js).
|
|
520
|
+
// bundle at process exit -- mirroring how the preload writes (hooks.js). Watch mode
|
|
521
|
+
// never reaches this hook: capture refuses it at watchRun above. esbuild / metro
|
|
512
522
|
// are deliberately NOT changed: their write hook fires once per build (no per-child
|
|
513
523
|
// amplification), and stasis build reads esbuild output in-process, so their synchronous
|
|
514
524
|
// write is load-bearing in a way webpack's per-child `done` write is not.
|
|
515
|
-
if (
|
|
525
|
+
if (this.#captureApplyCount === 1) this.#state.write()
|
|
516
526
|
else this.#deferWrite()
|
|
517
527
|
})
|
|
518
528
|
}
|
|
@@ -526,9 +536,14 @@ export class StasisWebpack {
|
|
|
526
536
|
// KNOWN LIMITATION: the flush runs at process exit, so a multi-compiler one-shot build inside
|
|
527
537
|
// a process that never exits normally -- one that stays alive after building and is then
|
|
528
538
|
// killed by a signal (SIGINT/SIGTERM/SIGKILL), which bypass both beforeExit and exit -- would
|
|
529
|
-
// not flush. That is the same capture-then-exit assumption the preload
|
|
530
|
-
// (hooks.js)
|
|
531
|
-
//
|
|
539
|
+
// not flush. That is the same capture-then-exit assumption the preload's ROOT write relies
|
|
540
|
+
// on (hooks.js). NB: the SIGTERM shard flush metro opts its workers into
|
|
541
|
+
// (EXODUS_STASIS_SHARD_SIGNAL_FLUSH, hooks.js) does NOT help here and setting it wouldn't
|
|
542
|
+
// either -- it covers a capturing CHILD's shard forward, while this deferred write is the
|
|
543
|
+
// MAIN process's State.write via THIS plugin's own beforeExit/exit listeners, which a signal
|
|
544
|
+
// death (or the loader's re-delivered SIGTERM) still bypasses. The common build-and-exit
|
|
545
|
+
// flow is unaffected, a single-compiler build never reaches here (it writes on `done`), and
|
|
546
|
+
// watch (`--watch`, dev-server) capture is refused outright at watchRun (see #applyCaptureMode).
|
|
532
547
|
#deferWrite() {
|
|
533
548
|
this.#deferredWriteDirty = true
|
|
534
549
|
if (this.#deferredWriteArmed) return
|