@exodus/stasis-plugins 1.0.0-beta.1 → 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.1",
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.1"
34
+ "@exodus/stasis-core": "1.0.0-beta.2"
35
35
  }
36
36
  }
@@ -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,19 +135,37 @@ 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
147
  // - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer
112
148
  // per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe
113
149
  // would otherwise keep attesting a file's first-build bytes after an edit changed them.
114
150
  // 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
118
- // before its exit hook (jest-worker forceExit / pool overflow) loses its shard SILENTLY at
119
- // capture time -- a later frozen run still catches the gap (fail-closed), but nothing warns
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
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
123
169
  // verify with `--lock=frozen`.
124
170
  // - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
125
171
  // by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
@@ -136,6 +182,13 @@ export class StasisMetro {
136
182
  #state
137
183
  #resources
138
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
+
139
192
  // Whether this instance already served one capture (#run). The serializer fires once per
140
193
  // one-shot `metro build`; a dev server re-invokes it per rebuild, which capture refuses --
141
194
  // see the guard in #run.
@@ -162,6 +215,26 @@ export class StasisMetro {
162
215
  'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' +
163
216
  'or "childProcess": true in stasis.config.json.'
164
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
+ }
165
238
  }
166
239
  // resources is a Config field now (validated + coordinated against any preload in
167
240
  // resolvePluginState); cache the resolved Set for the per-file classify hot path.
@@ -195,14 +268,11 @@ export class StasisMetro {
195
268
 
196
269
  #run(graph, preModules) {
197
270
  if (!this.#state) return
198
- if (this.#state.config.loadBundle) {
199
- throw new Error(
200
- 'StasisMetro: bundle=load is served by the companion worker transformer ' +
201
- "(transformer.transformerPath = '@exodus/stasis/metro-transformer'), not this serializer " +
202
- 'plugin -- Metro transforms in workers before serialization. Remove this plugin from your ' +
203
- 'serializer config in load mode, or use bundle=add/replace/frozen here to capture.'
204
- )
205
- }
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
206
276
  // Watch/dev-server capture is EXPLICITLY UNSUPPORTED -- refuse the second serialization
207
277
  // loudly before it captures anything. Capture's dedupe is keyed by PATH, not content
208
278
  // (#seen below, plus State.write's compare-and-skip caches), so on a rebuild where a
@@ -211,8 +281,8 @@ export class StasisMetro {
211
281
  // serializer fires exactly once per one-shot `metro build`; only a dev server
212
282
  // (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on
213
283
  // 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.)
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.)
216
286
  if (this.#ran) {
217
287
  throw new Error(
218
288
  'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
@@ -239,6 +309,42 @@ export class StasisMetro {
239
309
  // app module graph.
240
310
  if (preModules) this.#captureModules(preModules, entries)
241
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
+ }
242
348
  }
243
349
 
244
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
@@ -536,10 +536,14 @@ export class StasisWebpack {
536
536
  // KNOWN LIMITATION: the flush runs at process exit, so a multi-compiler one-shot build inside
537
537
  // a process that never exits normally -- one that stays alive after building and is then
538
538
  // killed by a signal (SIGINT/SIGTERM/SIGKILL), which bypass both beforeExit and exit -- would
539
- // not flush. That is the same capture-then-exit assumption the preload already relies on
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).
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).
543
547
  #deferWrite() {
544
548
  this.#deferredWriteDirty = true
545
549
  if (this.#deferredWriteArmed) return