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

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.
@@ -5,55 +5,19 @@ import { pathToFileURL } from 'node:url'
5
5
 
6
6
  import { State } from '@exodus/stasis-core/state'
7
7
 
8
- // Companion to the StasisMetro serializer plugin: the LOAD half of stasis for Metro.
9
- //
10
- // WHY A TRANSFORMER (the asymmetry that makes this possible):
11
- // Capture must MERGE every worker's observations into one lockfile/bundle, so it
12
- // runs in the main process (the serializer -- see metro.js). Load is the opposite:
13
- // it only ever READS an immutable bundle, and every worker reads the SAME bytes, so
14
- // there is nothing to synchronize between them. That makes the per-worker transformer
15
- // the natural seam for load -- each worker independently serves attested bytes, no IPC.
16
- //
17
- // HOW IT WIRES IN (metro.config.js, load mode only):
18
- // module.exports = {
19
- // transformer: {
20
- // transformerPath: require.resolve('@exodus/stasis/metro-transformer'),
21
- // },
22
- // }
23
- // // 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.
26
- //
27
- // Metro calls `transformerPath`'s `transform(config, projectRoot, filename, data,
28
- // options)` in each worker with `data` = the file's on-disk bytes (a Buffer) and
29
- // `filename` project-relative. We wrap the user's real transformer (default
30
- // `metro-transform-worker`, override via EXODUS_STASIS_METRO_BASE_TRANSFORMER) and,
31
- // in load mode, replace `data` with the bundle's attested bytes before delegating --
32
- // so the transform consumes bundle bytes, hash-verified, fail-closed.
33
- //
34
- // WHAT THIS DOES AND DOESN'T GUARANTEE:
35
- // - The bytes that get TRANSFORMED are the bundle's, verified by State.getFile
36
- // (against the lockfile when one is loaded; against the bundle's own bytes under
37
- // lock=none). A tampered on-disk source is therefore ignored for the build output
38
- // and, under a lockfile, rejected outright. An in-scope file the bundle doesn't
39
- // carry throws -- never a silent disk fallback. Out-of-scope files pass their disk
40
- // bytes through untouched (mirrors webpack/esbuild load scope handling).
41
- // - KNOWN LIMITATION: this does NOT let Metro build with the sources fully absent
42
- // from disk. Metro reads each file from disk in the worker (and hashes it in the
43
- // main process for its transform cache) BEFORE this transformer runs, and resolves
44
- // package.json via its own fs in the main process. Serving those reads from the
45
- // bundle would need fs interception across the main + worker processes (stasis
46
- // --mock territory), which is larger than this seam. So today's guarantee is
47
- // "build the bundle's attested bytes, fail closed on disk drift," not "build from
48
- // a bundle with no source tree." This mirrors the documented node_modules-crossing
49
- // gap in the webpack load path.
8
+ // Companion to the StasisMetro serializer: the LOAD half of stasis for Metro. Load only READS an
9
+ // immutable bundle and every worker reads the same bytes (no IPC), so a per-worker transformer is
10
+ // the natural seam. Wire it permanently in metro.config.js (transformerPath); outside load mode it
11
+ // is a transparent pass-through, and under load the serializer is too -- the mode picks the active
12
+ // half. In load mode, wrap the user's real transformer and replace Metro's on-disk `data` with the
13
+ // bundle's hash-verified bytes (fail-closed; out-of-scope files pass their disk bytes through).
14
+ // KNOWN LIMITATION: does NOT build with sources absent from disk -- Metro reads + hashes each file
15
+ // before this runs, so the guarantee is "build attested bytes, fail closed on disk drift."
50
16
 
51
17
  const require = createRequire(import.meta.url)
52
18
 
53
- // The real transformer we wrap. Default matches Metro's default; an app with a custom
54
- // transformer (e.g. a TS/Babel preset) points this at theirs so we sit transparently
55
- // in front of it. Resolved + required lazily (per worker process) so a non-load build
56
- // pays nothing and we don't require Metro internals at import time.
19
+ // The real transformer we wrap (default matches Metro's; override for a custom TS/Babel preset).
20
+ // Required lazily per worker so a non-load build pays nothing.
57
21
  const BASE_TRANSFORMER = process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker'
58
22
 
59
23
  let base
@@ -62,25 +26,30 @@ function getBase() {
62
26
  return base
63
27
  }
64
28
 
65
- // Per-worker load State, built once. We construct a normal State (it reads the same
66
- // EXODUS_STASIS_* env + stasis.config.json the rest of stasis does) and only act when
67
- // it resolves to load mode; any other mode -> null -> this transformer is a transparent
68
- // pass-through, so wiring it permanently in metro.config.js is safe.
29
+ // Per-worker load State, built once from the usual env + stasis.config.json. Non-load mode -> null
30
+ // -> transparent pass-through, so wiring it permanently is safe. A construction failure is cached
31
+ // and rethrown on every call -- never throw-once-then-silently-pass-through.
69
32
  let stateInited = false
70
33
  let loadState = null
34
+ let stateError = null
71
35
  function getLoadState() {
72
36
  if (!stateInited) {
73
- stateInited = true
74
- const state = new State(process.cwd())
75
- loadState = state.config.loadBundle ? state : null
37
+ try {
38
+ const state = new State(process.cwd())
39
+ loadState = state.config.loadBundle ? state : null
40
+ } catch (err) {
41
+ stateError = err
42
+ throw err
43
+ } finally {
44
+ stateInited = true
45
+ }
76
46
  }
47
+ if (stateError) throw stateError
77
48
  return loadState
78
49
  }
79
50
 
80
- // True iff `absolute` is a path the bundle is supposed to cover under load mode.
81
- // `state.relative` throws for paths outside the project root, which we treat as
82
- // out-of-scope (system files, scratch dirs) and defer to disk. Mirrors the webpack
83
- // plugin's load-mode inScope.
51
+ // True iff `absolute` is in scope for load mode. state.relative throws outside the project root
52
+ // (out-of-scope: system files, scratch) -> defer to disk. Mirrors the webpack plugin's inScope.
84
53
  function inScope(state, absolute) {
85
54
  try {
86
55
  state.relative(absolute)
@@ -94,14 +63,11 @@ function inScope(state, absolute) {
94
63
  export function transform(config, projectRoot, filename, data, options) {
95
64
  const state = getLoadState()
96
65
  if (state) {
97
- // `filename` is project-relative in Metro's transformer contract; resolve against
98
- // projectRoot (resolvePath ignores projectRoot when filename is already absolute).
66
+ // `filename` is project-relative in Metro's contract; resolve against projectRoot.
99
67
  const absolute = resolvePath(projectRoot, filename)
100
68
  if (inScope(state, absolute)) {
101
- // getFile re-verifies the hash against the lockfile on every read and throws on a
102
- // file the bundle doesn't carry -- so an in-scope path the bundle is missing
103
- // surfaces here as a hard build error, not a silent disk fallback. Code sources
104
- // come back as strings; resources as Buffers (already decoded from base64).
69
+ // getFile re-verifies the hash and throws on a file the bundle doesn't carry (fail-closed,
70
+ // no disk fallback). Code sources come back as strings; resources as Buffers.
105
71
  const { source } = state.getFile(pathToFileURL(absolute).toString())
106
72
  data = Buffer.isBuffer(source) ? source : Buffer.from(source)
107
73
  }
@@ -110,14 +76,9 @@ export function transform(config, projectRoot, filename, data, options) {
110
76
  }
111
77
 
112
78
  export function getCacheKey(config) {
113
- // Fold the wrapped transformer's own cache key in (so changing the base transformer
114
- // still invalidates), then add a stasis-load discriminator: load-mode transforms can
115
- // produce different bytes than a disk build for the same file (and Metro hashes the
116
- // on-disk bytes into its cache key BEFORE we substitute, so when disk == bundle the
117
- // content hash alone wouldn't tell a load result apart from a plain build), so their
118
- // cached results must not cross-pollinate. Derive the marker from the resolved load
119
- // State -- the SAME source of truth as transform() -- not the env var, so load activated
120
- // via stasis.config.json is keyed correctly too; the bundle path keys it to the bundle.
79
+ // Fold in the base transformer's cache key, then add a stasis-load discriminator so load results
80
+ // (which can differ from a disk build even when disk == bundle) don't cross-pollinate the cache.
81
+ // Derive the marker from the resolved load State (not the env) so config.json activation keys too.
121
82
  const baseKey = typeof getBase().getCacheKey === 'function' ? getBase().getCacheKey(config) : ''
122
83
  const state = getLoadState()
123
84
  const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'