@exodus/stasis-plugins 1.0.0-beta.2 → 1.0.0-beta.3

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.2",
3
+ "version": "1.0.0-beta.3",
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.2"
34
+ "@exodus/stasis-core": "1.0.0-beta.3"
35
35
  }
36
36
  }
package/src/esbuild.js CHANGED
@@ -7,11 +7,8 @@ import assert from 'node:assert/strict'
7
7
  import { Bundle } from '@exodus/stasis-core/bundle'
8
8
  import { resolvePluginState } from './plugins.js'
9
9
  import { State } from '@exodus/stasis-core/state'
10
- // Pre-patch snapshot, NOT `import { readFile } from 'node:fs/promises'`: under
11
- // `stasis run --fs=async` that builtin is monkey-patched, and this plugin loads after
12
- // the patch, so a direct import would route esbuild's own source reads through the
13
- // --fs capture hook. The snapshot in state-util (taken during the --import preload) is
14
- // the genuine reader.
10
+ // Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
11
+ // this plugin loads after, so a direct import would route esbuild's reads through the capture hook.
15
12
  import { realReadFile } from '@exodus/stasis-core/state-util'
16
13
  import { classifyExtension } from '@exodus/stasis-core/util'
17
14
 
@@ -20,31 +17,32 @@ export class StasisEsbuild {
20
17
  #state
21
18
  #resources
22
19
  #loaders
20
+ #transform
23
21
 
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().
22
+ // Build starts observed by this instance (across rebuilds and separate build()/context() calls);
23
+ // the second is refused in onStart.
27
24
  #captureBuildCount = 0
28
25
 
29
- constructor(options = {}, { loaders } = {}) {
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
+ constructor(options = {}, { loaders, transform } = {}) {
27
+ // A caller that owns a State (e.g. `stasis build`) can pass it directly; a foreign-copy State
28
+ // fails closed via the instanceof miss.
31
29
  const state = options instanceof State
32
30
  ? options
33
31
  : resolvePluginState('StasisEsbuild', options, process.cwd()).state
34
32
  this.#state = state // null when plugin should be inert
35
- // resources is a Config field now (validated + coordinated against any preload in
36
- // resolvePluginState); cache the resolved Set for the per-file classify hot path.
33
+ // Cache the resolved resources Set for the per-file classify hot path.
37
34
  this.#resources = state?.config.resources ?? new Set()
38
- // Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } so a .js file
39
- // carrying JSX -- the React Native convention -- parses; esbuild's default 'js'
40
- // loader rejects JSX). Keyed by extname WITH the dot; falls back to the
41
- // extension-derived loader (see #loaderFor) when an extension isn't overridden.
35
+ // Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } for RN's JSX-in-.js),
36
+ // keyed by extname with the dot; falls back to #loaderFor.
42
37
  this.#loaders = loaders ?? new Map()
38
+ // Load-mode-only code rewrite hook (e.g. `stasis build --platform=hermes` downleveling):
39
+ // (source, { path, loader }) => { contents, loader } | undefined, may be async. Never
40
+ // runs at capture -- capture attests the file's raw bytes.
41
+ this.#transform = transform
43
42
  }
44
43
 
45
- // The esbuild loader for a served file: an explicit per-extension override if the
46
- // caller configured one, else derived from the extension (.cjs/.mjs/.cts/.mts strip
47
- // their leading module letter to js/ts; other code extensions map 1:1 to a loader).
44
+ // esbuild loader for a served file: configured override, else derived from extension
45
+ // (.cjs/.mjs/.cts/.mts strip their module letter to js/ts).
48
46
  #loaderFor(path) {
49
47
  const ext = extname(path)
50
48
  return this.#loaders.get(ext) ?? ext.replace(/^\.[cm]?/u, '')
@@ -54,21 +52,12 @@ export class StasisEsbuild {
54
52
  return 'stasis'
55
53
  }
56
54
 
57
- setup = ({ onResolve, onLoad, onStart, onEnd, resolve }) => {
55
+ setup = ({ onResolve, onLoad, onStart, onEnd, resolve, initialOptions }) => {
58
56
  if (!this.#state) return // noop plugin
59
57
 
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.
58
+ // Watch/rebuild capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
59
+ // changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Rebuilds re-fire
60
+ // onStart against the same registrations, so an instance counter catches the second and errors.
72
61
  if (!this.#state.config.loadBundle) {
73
62
  onStart(() => {
74
63
  this.#captureBuildCount += 1
@@ -81,10 +70,8 @@ export class StasisEsbuild {
81
70
  })
82
71
  }
83
72
 
84
- // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
85
- // Standalone and sidecar States have no such hook, so the plugin writes them when the
86
- // build finishes -- but only on a clean build, otherwise a partial bundle/lockfile
87
- // would overwrite the user's good file.
73
+ // The preload writes itself (hooks.js); standalone/sidecar States are written on build finish --
74
+ // only on a clean build (a partial one would overwrite the user's good file).
88
75
  if (this.#state !== State.preload) {
89
76
  onEnd((result) => {
90
77
  if (result.errors.length > 0) return
@@ -92,61 +79,33 @@ export class StasisEsbuild {
92
79
  })
93
80
  }
94
81
 
95
- // Load mode: resolution comes from the bundle's import map, not esbuild's
96
- // resolver. The on-disk file may not even exist -- the bundle is the source
97
- // of truth (mirrors `stasis run --bundle=load`; see stasis-core/src/hooks.js).
98
- // We keep `namespace: 'file'` with the original absolute path, even though the
99
- // file isn't on disk: esbuild stats the path only when its own default resolver
100
- // produced it; paths returned from a plugin's onResolve are trusted as-is. That
101
- // gives the same output bytes as a capture-mode build -- esbuild's path-banner
102
- // comments, asset names, and source maps all use the original paths.
82
+ // Load mode: resolution comes from the bundle's import map, not esbuild's resolver -- the file
83
+ // may not be on disk. Keep namespace:'file' with the original absolute path (esbuild doesn't stat
84
+ // plugin-returned paths) so output bytes -- banners, asset names, source maps -- match a capture build.
103
85
  onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
104
86
  if (!this.#state.config.loadBundle) return undefined
105
- // Node built-ins (`constants`, `node:fs`, ...) are never carried in the bundle
106
- // and never recorded as import edges -- the capture side (and the Node run
107
- // loader, stasis-core/hooks.js) defer isBuiltin() specifiers before addImport
108
- // sees them. Returning undefined hands the builtin back to esbuild's own
109
- // resolution, which externalizes it under platform:'node' exactly as in a build
110
- // without this plugin; state.getImport would instead throw ERR_MODULE_NOT_FOUND
111
- // ("Cannot find module 'constants' imported from <file>"). Mirrors the webpack
112
- // plugin's load-mode builtin guard.
87
+ // Built-ins are never bundled or recorded as edges; hand them back to esbuild (undefined)
88
+ // so it externalizes under platform:'node', rather than getImport throwing ERR_MODULE_NOT_FOUND.
113
89
  if (isBuiltin(specifier)) return undefined
114
90
  const isEntry = resolveKind === 'entry-point'
115
91
  let url
116
92
  if (isEntry) {
117
- // esbuild's entryPoints are typically absolute, but a bare/relative entry
118
- // is anchored to cwd just like the run loader does (resolvePath(cwd, ...))
119
- // so the entry file doesn't need to be on disk.
93
+ // Bare/relative entries are anchored to cwd (like the run loader), so the file needn't be on disk.
120
94
  url = specifier.startsWith('file:')
121
95
  ? specifier
122
96
  : pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
123
97
  if (this.#state.config.full) this.#state.assertEntry(url)
124
98
  } else {
125
99
  const parentURL = pathToFileURL(importer).toString()
126
- // Forward import attributes (e.g. `with { type: 'json' }`) so a
127
- // plugin-captured bundle round-trips to plugin-load symmetrically: both
128
- // sides key the edge under `* (with: ...)` (no conditions because the
129
- // plugin doesn't know them). KNOWN GAP: a bundle captured by the Node
130
- // run loader keys edges under specific conditions like `node, import,
131
- // ... (with: ...)`; the plugin's `*`-keyed lookup misses that, and the
132
- // wildcard fallback in state.getImport only falls back to `*` (without
133
- // attrs), so a Node-loader-captured bundle with attributed imports
134
- // won't reload via the esbuild plugin without further work
135
- // (state.getImport would need a multi-conditions match scan, or the
136
- // Node loader would need to mirror under '*' too).
100
+ // Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
101
+ // plugin-load, both keyed under `* (with: ...)`. KNOWN GAP: a Node-loader-captured bundle
102
+ // keys attributed edges under specific conditions the plugin's `*` lookup won't match.
137
103
  try {
138
104
  ;({ url } = this.#state.getImport(parentURL, specifier, { importAttributes: attrs }))
139
105
  } catch (err) {
140
- // No attested edge. getImport tried the recorded edge under every conditions
141
- // bucket (resolveBundled), so a miss means the request names nothing the
142
- // bundle carries -- at a successful capture it was an EXTERNAL (a user
143
- // `external` entry; built-ins are handled above). Externals are never recorded
144
- // as edges, so return undefined to hand it back to esbuild, which externalizes it as it would
145
- // without this plugin. This relaxes only the resolve-time edge lookup:
146
- // a genuinely missing in-scope FILE still fails closed at onLoad
147
- // (state.getFile serves in-scope bytes from the bundle or throws), so no
148
- // disk fallback is introduced. A non-MODULE_NOT_FOUND error is a real
149
- // fault -- rethrow it.
106
+ // No attested edge -- at a successful capture that's an EXTERNAL (never recorded as an
107
+ // edge), so hand it back to esbuild. File BYTES still fail closed at onLoad.
108
+ // A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
150
109
  if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
151
110
  throw err
152
111
  }
@@ -162,12 +121,8 @@ export class StasisEsbuild {
162
121
  // Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
163
122
  const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
164
123
  if (res.errors.length > 0) return res
165
- // Warnings from sibling plugins or esbuild itself (deprecation notices, etc.) are
166
- // not our concern -- propagate them upward, don't crash.
167
- // A non-empty `suffix` is set by sibling plugins that want to preserve a
168
- // query-style suffix on the resolved path (some CSS-modules plugins do).
169
- // stasis doesn't know how to persist suffixes to the bundle, so we refuse
170
- // them explicitly rather than silently dropping the suffix at capture time.
124
+ // A non-empty `suffix` (some CSS-modules plugins set one) can't be persisted to the bundle,
125
+ // so refuse it rather than silently dropping it at capture.
171
126
  assert.equal(res.suffix, '',
172
127
  `StasisEsbuild: a sibling plugin set res.suffix='${res.suffix}' on '${specifier}'; ` +
173
128
  `stasis can't persist suffixes to the bundle. Disable that plugin or drop suffix from its resolve result.`)
@@ -182,13 +137,14 @@ export class StasisEsbuild {
182
137
  }],
183
138
  }
184
139
  }
185
- if (kind === 'code' && !isEntry) {
140
+ // Record the edge for BOTH code and resource targets (never entries): load mode resolves
141
+ // every specifier through this map, so a resource with no edge would miss at bundle=load and
142
+ // fall through to esbuild's disk resolver -- serving the asset UNATTESTED. kind is code|resource here.
143
+ if (!isEntry) {
186
144
  const parentURL = pathToFileURL(args.importer).toString()
187
145
  const url = pathToFileURL(res.path).toString()
188
- // Forward import attributes (e.g. `with { type: 'json' }`) symmetrically
189
- // with the load path: addImport stores under a conditions-with-attributes
190
- // key when attributes are present, so getImport at load time can find
191
- // the same edge.
146
+ // Forward import attributes symmetrically with load: addImport keys by attributes so
147
+ // getImport finds the same edge.
192
148
  this.#state.addImport(parentURL, specifier, url, { importAttributes: attrs })
193
149
  }
194
150
  }
@@ -199,52 +155,46 @@ export class StasisEsbuild {
199
155
  onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
200
156
  const kind = pluginData?.kind ?? 'skip'
201
157
  if (kind === 'skip') return undefined
202
- // Accept the `type` import attribute (e.g. `with { type: 'json' }`); reject
203
- // anything else loudly since saving import-attribute state to the lockfile
204
- // isn't designed yet.
158
+ // Accept only the `type` import attribute; other attributes aren't persistable to the lockfile yet.
205
159
  for (const k of Object.keys(attrs)) {
206
160
  assert.equal(k, 'type', `unsupported import attribute: ${k}`)
207
161
  }
208
162
 
209
- // Load mode: serve bytes the bundle attested. `getFile` verifies the hash
210
- // against the lockfile (when one is loaded), and throws on a file the
211
- // bundle doesn't carry -- a missing in-scope file is a hard error, not a
212
- // silent disk fallback. Resources return `{ contents }` only; esbuild's
213
- // loader config (file/dataurl/copy/...) interprets and emits them by
214
- // extension, the same way capture mode lets esbuild's loader machinery
215
- // handle resource emission.
163
+ // Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
164
+ // the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
216
165
  if (this.#state.config.loadBundle) {
217
166
  const { source } = this.#state.getFile(pathToFileURL(path).toString())
218
- if (kind === 'resource') return { contents: source }
219
- return { contents: source, loader: this.#loaderFor(path) }
167
+ if (kind === 'resource') {
168
+ // Plugin-provided contents with no `loader` default to `js` (esbuild applies the build's
169
+ // per-extension loader only on its own load path), so replay the configured loader from
170
+ // initialOptions.loader. No configured loader fails symmetrically with a capture build.
171
+ const loader = initialOptions.loader?.[extname(path)]
172
+ return loader ? { contents: source, loader } : { contents: source }
173
+ }
174
+ const loader = this.#loaderFor(path)
175
+ // A transform that declines (undefined) serves the attested bytes unchanged.
176
+ const transformed = await this.#transform?.(source, { path, loader })
177
+ return transformed ?? { contents: source, loader }
220
178
  }
221
179
 
222
180
  const source = await realReadFile(path)
223
181
 
224
182
  if (kind === 'resource') {
225
- // Mark this file as a resource. State picks the encoding from its bytes:
226
- // 'resource' (raw UTF-8 -- e.g. SVG stays human-readable in the bundle)
227
- // for valid UTF-8 content, 'resource:base64' for binary. We do NOT return
228
- // contents/loader: emission is esbuild's job via the build's own `loader`
229
- // config (file, dataurl, copy, ...), the same way webpack's file-loader
230
- // runs independently of stasis's afterResolve hook. An asset with no
231
- // configured loader errors in esbuild, which
232
- // is the user's responsibility (symmetric with file-loader being required).
183
+ // Mark as a resource (State picks 'resource' vs 'resource:base64' from bytes). Return no
184
+ // contents/loader: emission is esbuild's job via the build's own `loader` config.
233
185
  if (!this.#seen.has(path)) {
234
186
  this.#seen.add(path)
235
- this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'StasisEsbuild' })
187
+ this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'esbuild' })
236
188
  }
237
189
  return undefined
238
190
  }
239
191
 
240
- // Code-classified files must be UTF-8. A code extension with non-UTF-8 bytes
241
- // is malformed input (e.g. a .json mis-saved as UTF-16); refuse it rather
242
- // than silently encoding it as a resource and hiding the underlying problem.
192
+ // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
243
193
  assert.ok(isUtf8(source), `StasisEsbuild: code-classified file has non-UTF-8 bytes: ${path}`)
244
194
 
245
195
  if (!this.#seen.has(path)) {
246
196
  this.#seen.add(path)
247
- this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: 'StasisEsbuild' })
197
+ this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: 'esbuild' })
248
198
  }
249
199
 
250
200
  return { contents: source, loader: this.#loaderFor(path) }
@@ -5,56 +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) -- wire it PERMANENTLY, alongside the serializer half:
18
- // module.exports = {
19
- // transformerPath: require.resolve('@exodus/stasis/metro-transformer'), // top-level, a sibling of `transformer`
20
- // }
21
- // // run with EXODUS_STASIS_BUNDLE=load (+ EXODUS_STASIS_BUNDLE_FILE / LOCK / SCOPE),
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).
27
- //
28
- // Metro calls `transformerPath`'s `transform(config, projectRoot, filename, data,
29
- // options)` in each worker with `data` = the file's on-disk bytes (a Buffer) and
30
- // `filename` project-relative. We wrap the user's real transformer (default
31
- // `metro-transform-worker`, override via EXODUS_STASIS_METRO_BASE_TRANSFORMER) and,
32
- // in load mode, replace `data` with the bundle's attested bytes before delegating --
33
- // so the transform consumes bundle bytes, hash-verified, fail-closed.
34
- //
35
- // WHAT THIS DOES AND DOESN'T GUARANTEE:
36
- // - The bytes that get TRANSFORMED are the bundle's, verified by State.getFile
37
- // (against the lockfile when one is loaded; against the bundle's own bytes under
38
- // lock=none). A tampered on-disk source is therefore ignored for the build output
39
- // and, under a lockfile, rejected outright. An in-scope file the bundle doesn't
40
- // carry throws -- never a silent disk fallback. Out-of-scope files pass their disk
41
- // bytes through untouched (mirrors webpack/esbuild load scope handling).
42
- // - KNOWN LIMITATION: this does NOT let Metro build with the sources fully absent
43
- // from disk. Metro reads each file from disk in the worker (and hashes it in the
44
- // main process for its transform cache) BEFORE this transformer runs, and resolves
45
- // package.json via its own fs in the main process. Serving those reads from the
46
- // bundle would need fs interception across the main + worker processes (stasis
47
- // --mock territory), which is larger than this seam. So today's guarantee is
48
- // "build the bundle's attested bytes, fail closed on disk drift," not "build from
49
- // a bundle with no source tree." This mirrors the documented node_modules-crossing
50
- // 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."
51
16
 
52
17
  const require = createRequire(import.meta.url)
53
18
 
54
- // The real transformer we wrap. Default matches Metro's default; an app with a custom
55
- // transformer (e.g. a TS/Babel preset) points this at theirs so we sit transparently
56
- // in front of it. Resolved + required lazily (per worker process) so a non-load build
57
- // 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.
58
21
  const BASE_TRANSFORMER = process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker'
59
22
 
60
23
  let base
@@ -63,25 +26,30 @@ function getBase() {
63
26
  return base
64
27
  }
65
28
 
66
- // Per-worker load State, built once. We construct a normal State (it reads the same
67
- // EXODUS_STASIS_* env + stasis.config.json the rest of stasis does) and only act when
68
- // it resolves to load mode; any other mode -> null -> this transformer is a transparent
69
- // 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.
70
32
  let stateInited = false
71
33
  let loadState = null
34
+ let stateError = null
72
35
  function getLoadState() {
73
36
  if (!stateInited) {
74
- stateInited = true
75
- const state = new State(process.cwd())
76
- 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
+ }
77
46
  }
47
+ if (stateError) throw stateError
78
48
  return loadState
79
49
  }
80
50
 
81
- // True iff `absolute` is a path the bundle is supposed to cover under load mode.
82
- // `state.relative` throws for paths outside the project root, which we treat as
83
- // out-of-scope (system files, scratch dirs) and defer to disk. Mirrors the webpack
84
- // 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.
85
53
  function inScope(state, absolute) {
86
54
  try {
87
55
  state.relative(absolute)
@@ -95,14 +63,11 @@ function inScope(state, absolute) {
95
63
  export function transform(config, projectRoot, filename, data, options) {
96
64
  const state = getLoadState()
97
65
  if (state) {
98
- // `filename` is project-relative in Metro's transformer contract; resolve against
99
- // projectRoot (resolvePath ignores projectRoot when filename is already absolute).
66
+ // `filename` is project-relative in Metro's contract; resolve against projectRoot.
100
67
  const absolute = resolvePath(projectRoot, filename)
101
68
  if (inScope(state, absolute)) {
102
- // getFile re-verifies the hash against the lockfile on every read and throws on a
103
- // file the bundle doesn't carry -- so an in-scope path the bundle is missing
104
- // surfaces here as a hard build error, not a silent disk fallback. Code sources
105
- // 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.
106
71
  const { source } = state.getFile(pathToFileURL(absolute).toString())
107
72
  data = Buffer.isBuffer(source) ? source : Buffer.from(source)
108
73
  }
@@ -111,14 +76,9 @@ export function transform(config, projectRoot, filename, data, options) {
111
76
  }
112
77
 
113
78
  export function getCacheKey(config) {
114
- // Fold the wrapped transformer's own cache key in (so changing the base transformer
115
- // still invalidates), then add a stasis-load discriminator: load-mode transforms can
116
- // produce different bytes than a disk build for the same file (and Metro hashes the
117
- // on-disk bytes into its cache key BEFORE we substitute, so when disk == bundle the
118
- // content hash alone wouldn't tell a load result apart from a plain build), so their
119
- // cached results must not cross-pollinate. Derive the marker from the resolved load
120
- // State -- the SAME source of truth as transform() -- not the env var, so load activated
121
- // 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.
122
82
  const baseKey = typeof getBase().getCacheKey === 'function' ? getBase().getCacheKey(config) : ''
123
83
  const state = getLoadState()
124
84
  const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'