@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/stasis-plugins",
3
- "version": "1.0.0-beta.0",
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.0"
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
@@ -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, load mode only):
17
+ // HOW IT WIRES IN (metro.config.js) -- wire it PERMANENTLY, alongside the serializer half:
18
18
  // module.exports = {
19
- // transformer: {
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". Do NOT also wire the StasisMetro
25
- // // serializer plugin in load mode -- capture and load are separate wirings.
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 `transformer.transformerPath`): unlike capture, load only ever
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. This serializer therefore REJECTS bundle=load.
92
- // Capture modes (lock/bundle add|replace|frozen, and lock=none/ignore) all work.
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(require('./metro.config.base'), { /* scope, lock, ... */ })
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
- // - 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`.
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
- if (this.#state.config.loadBundle) {
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: 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.'
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. 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.
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 when the bundle cannot grow across `done`s:
501
- // - watch mode: a watcher keeps the event loop alive, so a deferred flush would never
502
- // fire; per-rebuild freshness is the point, and State.write's compare-and-skip keeps
503
- // an unchanged rebuild a ~no-op; and
504
- // - a single compiler: only one `done`, so there is no N-write amplification and no
505
- // reason to give up the old write-by-`done` durability (the bundle is on disk by the
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). esbuild / metro
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 (watching || this.#captureApplyCount === 1) this.#state.write()
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 already relies on
530
- // (hooks.js) and metro documents; the common build-and-exit and watch (`--watch`, dev-server)
531
- // flows are unaffected, and a single-compiler build never reaches here (it writes on `done`).
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