@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 +2 -2
- package/src/metro-transformer.js +7 -6
- package/src/metro.js +129 -23
- package/src/plugins.js +3 -0
- package/src/webpack.js +8 -4
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/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,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
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
215
|
-
// companion worker transformer
|
|
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
|
|
540
|
-
// (hooks.js)
|
|
541
|
-
//
|
|
542
|
-
//
|
|
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
|