@exodus/stasis-plugins 1.0.0-beta.0 → 1.0.0-beta.1

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.1",
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.1"
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.js CHANGED
@@ -108,13 +108,19 @@ function metroDefaultSerializer() {
108
108
  // hook) is a lower-coverage fallback -- see its note.
109
109
  //
110
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
111
+ // - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer
112
+ // per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe
113
+ // would otherwise keep attesting a file's first-build bytes after an edit changed them.
114
+ // Metro gives the serializer no watch signal on the first build, so the first rebuild is the
115
+ // earliest refusal point. Load mode (the companion worker transformer) is unaffected and
116
+ // runs fine under a dev server -- it only reads immutable attested bytes.
117
+ // - Child-process capture is best-effort within that one shot. A worker KILLED by signal
112
118
  // before its exit hook (jest-worker forceExit / pool overflow) loses its shard SILENTLY at
113
119
  // 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`.
120
+ // during capture. And it relies on the loader's exit-time write, which standalone (no stasis
121
+ // loader) Metro never reaches -- it mints no shard dir and produces no toolchain-complete
122
+ // lockfile. Use a one-shot `metro build` under `stasis run --child-process` to capture, then
123
+ // verify with `--lock=frozen`.
118
124
  // - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
119
125
  // by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
120
126
  // scales/files arrays), NOT as separate graph modules, so only the base file is attested
@@ -130,6 +136,11 @@ export class StasisMetro {
130
136
  #state
131
137
  #resources
132
138
 
139
+ // Whether this instance already served one capture (#run). The serializer fires once per
140
+ // one-shot `metro build`; a dev server re-invokes it per rebuild, which capture refuses --
141
+ // see the guard in #run.
142
+ #ran = false
143
+
133
144
  constructor(options = {}) {
134
145
  const { state } = resolvePluginState('StasisMetro', options, process.cwd())
135
146
  this.#state = state // null when the plugin should be inert (Rule 7)
@@ -192,15 +203,31 @@ export class StasisMetro {
192
203
  'serializer config in load mode, or use bundle=add/replace/frozen here to capture.'
193
204
  )
194
205
  }
206
+ // Watch/dev-server capture is EXPLICITLY UNSUPPORTED -- refuse the second serialization
207
+ // loudly before it captures anything. Capture's dedupe is keyed by PATH, not content
208
+ // (#seen below, plus State.write's compare-and-skip caches), so on a rebuild where a
209
+ // file's bytes changed the plugin would skip re-capture: the emitted bundle would use the
210
+ // new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones. The
211
+ // serializer fires exactly once per one-shot `metro build`; only a dev server
212
+ // (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on
213
+ // the FIRST build, so the first rebuild is the earliest point capture can refuse. (Load
214
+ // mode has no such hazard and legitimately runs under a dev server -- it lives in the
215
+ // companion worker transformer, rejected above, which only READS immutable attested bytes.)
216
+ if (this.#ran) {
217
+ throw new Error(
218
+ 'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
219
+ )
220
+ }
221
+ this.#ran = true
195
222
  this.#capture(graph, preModules)
196
223
 
197
224
  // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
198
225
  // Standalone and sidecar States have no such hook, so the plugin writes them here. The
199
226
  // serializer only runs once Metro has successfully built + transformed the whole graph
200
227
  // (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.
228
+ // webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate -- and it runs at
229
+ // most once per instance (the rebuild guard above), so this write is the one-shot build's
230
+ // single flush.
204
231
  if (this.#state !== State.preload) this.#state.write()
205
232
  }
206
233
 
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
  }
@@ -527,8 +537,9 @@ export class StasisWebpack {
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
539
  // 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`).
540
+ // (hooks.js) and metro documents; the common build-and-exit flow is unaffected, a
541
+ // single-compiler build never reaches here (it writes on `done`), and watch (`--watch`,
542
+ // dev-server) capture is refused outright at watchRun (see #applyCaptureMode).
532
543
  #deferWrite() {
533
544
  this.#deferredWriteDirty = true
534
545
  if (this.#deferredWriteArmed) return