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

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/src/plugins.js CHANGED
@@ -1,9 +1,5 @@
1
- // Plugin <-> preload coordination (resolvePluginState): decides which State a bundler
2
- // plugin drives, given its options and the ambient preload. The per-file classification
3
- // helpers (pathExt / parseResourcesOption / classifyExtension) live in util.js -- they're
4
- // pure and shared with State's `--fs` capture, so both paths classify against one source
5
- // of truth. Kept out of state.js because the coordination only consults State.preload +
6
- // Config helpers, never State internals.
1
+ // Plugin <-> preload coordination (resolvePluginState): decides which State a bundler plugin
2
+ // drives, given its options and the ambient preload.
7
3
 
8
4
  import assert from 'node:assert/strict'
9
5
 
@@ -12,58 +8,42 @@ import { ensureStateForLoader, isLoaderInstalled } from '@exodus/stasis-core/hoo
12
8
  import { State } from '@exodus/stasis-core/state'
13
9
  import { extSetsEqual, parseResourcesOption } from '@exodus/stasis-core/util'
14
10
 
15
- // Decides which State a bundler plugin (StasisWebpack / StasisEsbuild) should run against,
16
- // given its constructor options and the ambient preload (or lack thereof). Returns either
17
- // { state: null, isNoop: true } -- plugin should register no hooks and become inert -- or
18
- // { state, isNoop: false } -- plugin should drive the returned State.
19
- //
20
- // The lockfile is always unified: a plugin never specifies its own lockfile position
21
- // (validatePluginOptions rejects `lockFile`). When an ambient preload exists the plugin
22
- // MUST share it -- it either reuses the preload State outright or runs as a sidecar that
23
- // shares the preload's hashes/entries/modules + lockfile data. Only the BUNDLE may split
24
- // off per-plugin (via bundleFile, a sidecar). A process that genuinely needs a non-default
25
- // lockfile location sets it process-wide (Config / EXODUS_STASIS_LOCK_FILE), not per-plugin.
26
- //
27
- // Rules:
28
- // 1. Plugin asking for any lockfile mode other than 'none' / 'ignore' requires a preload.
29
- // Without one it's a hard throw -- the lockfile would otherwise only cover the bundled
30
- // app and silently miss every dependency the bundler itself pulled in.
31
- // 2. With a preload, the plugin's lock mode must agree with the preload's.
32
- // 3. Lockfile is unified: when plugin and preload align we reuse the preload State so a
33
- // single lockfile is written.
34
- // 4. Bundle may split: with a preload that already has bundle on, the plugin cannot disable
35
- // bundle and the bundle mode must match. The plugin's bundleFile, however, may differ
36
- // -- in which case we construct a sidecar State sharing the preload's lockfile data.
11
+ // Decides which State a bundler plugin runs against, given its options and the ambient preload.
12
+ // Returns { state: null, isNoop: true } (inert -- register no hooks) or { state, isNoop: false }.
13
+ // The lockfile is always unified (plugins can't set lockFile); only the BUNDLE may split per-plugin
14
+ // (bundleFile -> sidecar). Rules, referenced by number in the code below:
15
+ // 0. No preload, not under `stasis run`, no lock/bundle option -> inert (safe on a plain build).
16
+ // 1. A writing/verifying lock mode without a preload throws (it would miss the bundler's own deps).
17
+ // 2. With a preload, the plugin's lock mode must agree with it.
18
+ // 3. When plugin and preload align, reuse the preload State (single lockfile).
19
+ // 4. Preload with bundle on: plugin can't disable it and the mode must match; bundleFile may differ.
37
20
  // 5. Same bundleFile (or unspecified) reuses the preload State.
38
- // 6. Different bundleFile creates a sidecar.
39
- // 7. Plugin with lock='none' AND bundle='none' and no preload is a no-op (lets framework
40
- // plugins be env-controlled off without throwing on the lock=none/bundle=none invariant).
21
+ // 6. Different bundleFile creates a sidecar sharing the preload's lockfile data.
22
+ // 7. lock='none' AND bundle='none' with no preload is a no-op (env-controlled-off framework plugins).
41
23
  export function resolvePluginState(label, options, cwd) {
42
24
  validatePluginOptions(label, options)
43
- const { scope, lock, bundle, bundleFile, resourcesBundleFile, debug, childProcess, resources } = options
44
- // Same-instance fast path: the plugin's stasis-core IS the binary's, the
45
- // hooks are installed, but the lazy initState hasn't fired yet (plugin
46
- // ran AHEAD of the first user-file load -- webpack constructs plugins
47
- // during config-parse before user code executes). Force-init now so the
48
- // plugin lands in the ambient branch and inherits the loader's Config.
49
- // We deliberately do NOT do this when the loader is a SEPARATE module
50
- // instance (different stasis-core copy in the plugin's node_modules vs
51
- // the binary's): force-init there would create a phantom State.preload
52
- // with its own beforeExit/exit save handlers that race the real loader's
53
- // lockfile write. Cross-instance detection lives below via env vars.
25
+ const { scope, lock, bundle, bundleFile, resourcesBundleFile, debug, childProcess, packageJSON, resources } = options
26
+ // Same-instance fast path: loader installed but lazy initState hasn't fired (webpack builds plugins
27
+ // before user code). Force-init so the plugin lands in the ambient branch. NOT for a separate
28
+ // stasis-core copy -- that would mint a phantom State.preload racing the real loader's write.
54
29
  if (!State.preload && isLoaderInstalled()) ensureStateForLoader()
55
30
  const ambient = State.preload
56
31
 
57
32
  if (!ambient) {
33
+ // "Under stasis run" detection: the bin script always exports EXODUS_STASIS_LOCK, so it's a
34
+ // process-wide signal regardless of which stasis-core copy hosts this code.
35
+ const underStasisRun = process.env.EXODUS_STASIS_LOCK !== undefined
36
+
37
+ // Rule 0: no preload, not under `stasis run`, no lock/bundle option -> do nothing, so a bare
38
+ // plugin sits inertly in a plain build. Capture is governed elsewhere (env or explicit options).
39
+ if (!underStasisRun && lock === undefined && bundle === undefined) {
40
+ return { state: null, isNoop: true }
41
+ }
42
+
58
43
  if (lock === 'none' && bundle === 'none') return { state: null, isNoop: true } // Rule 7
59
- // Rule 7 needs BOTH to be 'none'. A partial opt-out -- one field is 'none' but
60
- // the other was simply omitted -- would otherwise fall through to either a
61
- // misleading Rule 1 throw about the default lockfile mode, or to Config's
62
- // generic "stasis needs a lockfile or a bundle" RangeError. Catch the partial
63
- // case explicitly. Only fires when the OTHER field is undefined: an active
64
- // bundle mode like `bundle:'load'` / `'frozen'` / `'add'` paired with
65
- // `lock:'none'` is a legitimate "bundle as trust root" config that Config
66
- // accepts (see config.js's bundle=load / bundle=frozen rules).
44
+ // A partial opt-out (one field 'none', the other omitted) would otherwise hit a misleading
45
+ // Rule 1 throw or Config's generic error; catch it explicitly. An active bundle mode paired
46
+ // with lock='none' is a legitimate "bundle as trust root" config, so only fire when the other is undefined.
67
47
  if ((lock === 'none' && bundle === undefined) || (bundle === 'none' && lock === undefined)) {
68
48
  throw new Error(
69
49
  `${label}: to opt out of stasis without a preload, pass BOTH lock='none' and bundle='none' ` +
@@ -71,18 +51,9 @@ export function resolvePluginState(label, options, cwd) {
71
51
  `bundle=${bundle === undefined ? 'default' : `'${bundle}'`})`
72
52
  )
73
53
  }
74
- // Cross-instance "running under stasis run" detection: `stasis run`'s
75
- // bin script always sets EXODUS_STASIS_LOCK on the spawned child (even
76
- // for the default `--lock=add`), so the env var is a reliable
77
- // process-wide signal regardless of which stasis-core copy is hosting
78
- // this code. When set, the runtime's loader is providing full lockfile
79
- // coverage for the process; the plugin's lockfile (at its own path,
80
- // typically a sidecar like `sources.stasis.lock.json`) is a sibling
81
- // artifact covering bundler-side resolutions, and the "lock=add needs
82
- // a preload" rationale doesn't apply. Skip that rule under stasis run;
83
- // Config below will pick up env-var defaults (lock/scope/bundle) for
84
- // the standalone plugin State.
85
- const underStasisRun = process.env.EXODUS_STASIS_LOCK !== undefined
54
+ // A writing/verifying lock (add|replace|frozen) still needs a preload unless under stasis run
55
+ // (whose loader gives full coverage). `lock` may be undefined here if only a writing `bundle`
56
+ // was passed, so the throw below distinguishes a defaulted mode from an explicit one.
86
57
  const effectiveLock = lock ?? DEFAULT_LOCK
87
58
  if (!underStasisRun && effectiveLock !== 'none' && effectiveLock !== 'ignore') {
88
59
  const phrasing = lock === undefined
@@ -96,16 +67,12 @@ export function resolvePluginState(label, options, cwd) {
96
67
  return { state: new State(cwd, options), isNoop: false }
97
68
  }
98
69
 
99
- // Preload is active. Since `lockFile` is not a plugin option, the lockfile is
100
- // necessarily the ambient's -- the plugin shares it (reuse or sidecar below).
101
- // Enforce coordination on the remaining fields before deciding which.
70
+ // Preload active: the lockfile is necessarily the ambient's. Enforce field coordination, then reuse or sidecar.
102
71
  const pc = ambient.config
103
72
  if (lock !== undefined && lock !== pc.lock) {
104
73
  throw new Error(`${label}: lock='${lock}' conflicts with active preload lock='${pc.lock}'`)
105
74
  }
106
- // scope/debug/childProcess must agree with the preload on every preload-coupled path -- reuse
107
- // adopts preload's config wholesale, and sidecar copies it field by field. Catch a
108
- // disagreement here rather than silently overriding the user's value.
75
+ // scope/debug/childProcess must agree with the preload (reuse adopts it, sidecar copies it).
109
76
  if (scope !== undefined && scope !== pc.scope) {
110
77
  throw new Error(`${label}: scope='${scope}' conflicts with active preload scope='${pc.scope}'`)
111
78
  }
@@ -115,10 +82,11 @@ export function resolvePluginState(label, options, cwd) {
115
82
  if (childProcess !== undefined && childProcess !== pc.childProcess) {
116
83
  throw new Error(`${label}: childProcess=${childProcess} conflicts with active preload childProcess=${pc.childProcess}`)
117
84
  }
118
- // resources is process-wide (a Config field), so a plugin's allowlist must agree with
119
- // the preload's -- reuse adopts it wholesale, a sidecar inherits it. Compare the parsed
120
- // sets (order/dupes/case don't matter), failing on a real disagreement rather than
121
- // letting one plugin silently widen the attested asset set.
85
+ if (packageJSON !== undefined && packageJSON !== pc.packageJSON) {
86
+ throw new Error(`${label}: packageJSON=${packageJSON} conflicts with active preload packageJSON=${pc.packageJSON}`)
87
+ }
88
+ // resources is process-wide, so a plugin's allowlist must agree with the preload's -- compare
89
+ // parsed sets (order/dupes/case-insensitive) to prevent one plugin silently widening the asset set.
122
90
  if (resources !== undefined) {
123
91
  const parsed = parseResourcesOption(label, resources)
124
92
  if (!extSetsEqual(parsed, pc.resources)) {
@@ -128,10 +96,8 @@ export function resolvePluginState(label, options, cwd) {
128
96
  }
129
97
  }
130
98
 
131
- // A plugin instance is attaching to the preload now (its constructor called us). Mark it so the
132
- // preload's bundle `reason` map can tell apart files run merely fs-READS during the build (the
133
- // plugin's modules, e.g. webpack+babel reading app source through --fs) from genuine run
134
- // dependencies recorded before any plugin existed. See State#bundleReason.
99
+ // Mark the plugin attached so the preload's bundle `reason` map can distinguish files merely
100
+ // fs-READ during the build from genuine run dependencies recorded before any plugin existed.
135
101
  ambient.markPluginAttached()
136
102
 
137
103
  if (pc.bundle) {
@@ -151,8 +117,7 @@ export function resolvePluginState(label, options, cwd) {
151
117
  assertOptionsMatchConfig(pc, options)
152
118
  return { state: ambient, isNoop: false }
153
119
  }
154
- // Rule 6: different path -- sidecar inherits preload's modes, overrides bundleFile
155
- // (and resourcesBundleFile if the plugin provided one for a split layout).
120
+ // Rule 6: different path -- sidecar inherits preload's modes, overrides bundleFile.
156
121
  const sidecar = new State(cwd, {
157
122
  parent: ambient,
158
123
  scope: pc.scope,
@@ -162,21 +127,19 @@ export function resolvePluginState(label, options, cwd) {
162
127
  resourcesBundleFile,
163
128
  debug: pc.debug,
164
129
  childProcess: pc.childProcess,
130
+ packageJSON: pc.packageJSON,
165
131
  resources: [...pc.resources],
132
+ // carried over: sidecars skip stasis.config.json discovery
133
+ brotliQuality: pc.brotliQuality,
166
134
  })
167
135
  return { state: sidecar, isNoop: false }
168
136
  }
169
137
 
170
138
  // Preload exists but has no bundle.
171
139
  if (bundle === undefined || bundle === 'none' || bundle === 'ignore') {
172
- // Plugin doesn't want a bundle either: just reuse preload for lockfile coverage.
173
- // bundle='ignore' here legitimately disagrees with preload's bundleMode ('none'),
174
- // both mean "no bundle written by the plugin", so skip the bundle field in the
175
- // cross-check rather than letting assertOptionsMatchConfig flag the difference.
176
- // Defense-in-depth: the strip is only safe when the preload's bundleMode is
177
- // actually in the no-bundle equivalence class. If a future bundleMode reads as
178
- // `!pc.bundle` but means something else, the strip would silently mask a real
179
- // disagreement -- the assert pins the contract to today's two values.
140
+ // Plugin wants no bundle either: reuse preload for lockfile coverage. bundle='ignore' vs the
141
+ // preload's 'none' both mean "no plugin bundle", so strip the bundle field from the cross-check;
142
+ // the assert pins that strip to today's no-bundle values so a future mode can't silently mask a disagreement.
180
143
  assert.ok(pc.bundleMode === 'none' || pc.bundleMode === 'ignore',
181
144
  `unexpected preload bundleMode='${pc.bundleMode}' in the no-bundle branch`)
182
145
  const { bundle: _b, ...rest } = options
@@ -198,7 +161,9 @@ export function resolvePluginState(label, options, cwd) {
198
161
  resourcesBundleFile,
199
162
  debug: pc.debug,
200
163
  childProcess: pc.childProcess,
164
+ packageJSON: pc.packageJSON,
201
165
  resources: [...pc.resources],
166
+ brotliQuality: pc.brotliQuality, // carried over, like the Rule 6 sidecar above
202
167
  })
203
168
  return { state: sidecar, isNoop: false }
204
169
  }
package/src/rollup.js ADDED
@@ -0,0 +1,327 @@
1
+ import assert from 'node:assert/strict'
2
+ import { isUtf8 } from 'node:buffer'
3
+ import { existsSync } from 'node:fs'
4
+ import { isBuiltin } from 'node:module'
5
+ import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+
8
+ import { Bundle } from '@exodus/stasis-core/bundle'
9
+ import { resolvePluginState } from './plugins.js'
10
+ import { State } from '@exodus/stasis-core/state'
11
+ // Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
12
+ // this plugin loads after, so a direct import would route rollup's reads through the capture hook.
13
+ import { realReadFile } from '@exodus/stasis-core/state-util'
14
+ import { classifyExtension } from '@exodus/stasis-core/util'
15
+
16
+ // Rollup chains resolveId/load hooks first-non-null-wins, so put this plugin FIRST in `plugins`:
17
+ // listed later, a sibling resolver (node-resolve, commonjs) answers before stasis sees the edge --
18
+ // capture would under-record specifiers and load mode would fall back to sibling disk resolution
19
+ // (file BYTES still fail closed at the load hook, which serves every in-scope path it reaches).
20
+ export class StasisRollup {
21
+ #seen = new Set()
22
+ #state
23
+ #resources
24
+
25
+ // Build starts observed by this instance (watch rebuilds and separate rollup() calls sharing one
26
+ // plugin object both re-fire buildStart); the second is refused there.
27
+ #captureBuildCount = 0
28
+
29
+ name = 'stasis'
30
+
31
+ constructor(options = {}, unsupported = undefined) {
32
+ // StasisEsbuild's second { loaders, transform } bag has no rollup analogue (transforms run as
33
+ // ordinary rollup plugins after this one) -- refuse it so a port doesn't silently drop it.
34
+ assert.equal(unsupported, undefined,
35
+ 'StasisRollup takes a single options argument -- there is no esbuild-style { loaders, transform } bag (run transforms as rollup plugins after this one)')
36
+ // A caller that owns a State (e.g. tests) can pass it directly; a foreign-copy State fails
37
+ // closed via the instanceof miss.
38
+ const state = options instanceof State
39
+ ? options
40
+ : resolvePluginState('StasisRollup', options, process.cwd()).state
41
+ this.#state = state
42
+ // Cache the resolved resources Set for the per-file classify hot path.
43
+ this.#resources = state?.config.resources ?? new Set()
44
+ if (!state) return // noop plugin: no hooks assigned, rollup sees only `name`
45
+
46
+ // Hooks are per-instance functions, not prototype methods: rollup invokes them with `this`
47
+ // bound to its PluginContext (whose resolve/getModuleInfo the hooks need), so instance state
48
+ // has to arrive via the closure instead.
49
+ const plugin = this
50
+ this.resolveId = async function (source, importer, opts) {
51
+ return plugin.#resolveId(this, source, importer, opts)
52
+ }
53
+ this.load = async function (id) {
54
+ return plugin.#load(this, id)
55
+ }
56
+
57
+ // A warm `cache:` from a previous build makes rollup reuse cached module code and dependency
58
+ // resolutions WITHOUT calling resolveId/load, so a capture would silently drop those modules'
59
+ // edges and bytes from the attestation, and a load-mode build would consume cached code no
60
+ // hook re-verified. Strip it (rollup still builds a fresh cache for this run) -- StasisMetro's
61
+ // withStasis drops Metro's cacheStores for the same hazard. `cache: true/false` carries no
62
+ // prior modules, so only an object is a hazard.
63
+ this.options = (inputOptions) => {
64
+ if (typeof inputOptions.cache !== 'object' || inputOptions.cache === null) return null
65
+ console.warn(
66
+ '[stasis] StasisRollup: ignoring the `cache` option for this build -- cached modules ' +
67
+ 'bypass the stasis hooks, so their imports and bytes would go unattested at capture and ' +
68
+ 'unverified at load'
69
+ )
70
+ return { ...inputOptions, cache: undefined }
71
+ }
72
+
73
+ if (!state.config.loadBundle) {
74
+ // Watch/rebuild capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
75
+ // changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Watch rebuilds
76
+ // (and a second rollup() call reusing this instance) re-fire buildStart, so an instance counter
77
+ // catches the second and errors.
78
+ this.buildStart = () => {
79
+ plugin.#captureBuildCount += 1
80
+ if (plugin.#captureBuildCount === 1) return
81
+ throw new Error(
82
+ 'StasisRollup: watch/rebuild is not supported for capture -- run a one-shot build (rebuilds would silently attest stale content)'
83
+ )
84
+ }
85
+
86
+ // Capture epilogue: flag entries from the completed graph, then persist. resolveId's
87
+ // opts.isEntry is never trusted for attestation -- rollup defaults it to !importer, so a
88
+ // sibling's bare `this.resolve(id)` probe looks exactly like an entry and would widen the
89
+ // attested set of runnable roots. The graph's ModuleInfo.isEntry is authoritative (real
90
+ // `input` entries and emitFile'd chunks -- both genuinely run as roots; probes never join
91
+ // the graph).
92
+ this.buildEnd = function (error) {
93
+ return plugin.#buildEnd(this, error)
94
+ }
95
+ }
96
+ }
97
+
98
+ // Rollup asset plugins (url/image/...) read their file from disk themselves inside their own
99
+ // load hook -- there is no seam to hand them attested bytes (load hooks don't chain), so a
100
+ // resource under bundle=load can't be served faithfully and must fail loudly, not fall back to
101
+ // unattested disk bytes.
102
+ #refuseResourceLoad(path) {
103
+ throw new Error(
104
+ `StasisRollup: bundle=load can't serve resource '${path}' -- rollup asset plugins read ` +
105
+ 'from disk directly, so attested replay is not possible; keep sources on disk and use a ' +
106
+ 'capture/verify mode instead'
107
+ )
108
+ }
109
+
110
+ // Accept only the `type` import attribute; other attributes aren't persistable to the lockfile
111
+ // yet. Checked only where an edge is recorded (addImport) or queried (getImport), so an
112
+ // attributed EXTERNAL passes through untouched -- exactly as under StasisEsbuild, whose
113
+ // equivalent assert sits in onLoad, which externals never reach.
114
+ #assertPersistableAttributes(attrs) {
115
+ for (const k of Object.keys(attrs)) {
116
+ assert.equal(k, 'type', `unsupported import attribute: ${k}`)
117
+ }
118
+ }
119
+
120
+ // Throws when `id` is a '?'/'#'-suffixed variant of an on-disk path (rollup's plugin-param
121
+ // convention, e.g. './icon.svg?url'): the underlying bytes feed the build, but the suffixed key
122
+ // can't be persisted or replayed, so skipping it would silently leave a build input unattested
123
+ // (a frozen run would be blind to tampering). The esbuild plugin refuses its `suffix` analogue
124
+ // the same way. A fully virtual id (nothing on disk under the stripped path either) is a
125
+ // sibling plugin's own module and stays skippable, like \0-prefixed ids.
126
+ #refuseSuffixedOnDisk(id) {
127
+ const stripped = id.split(/[?#]/u, 1)[0]
128
+ if (stripped !== id && existsSync(stripped)) {
129
+ throw new Error(
130
+ `StasisRollup: can't attest suffixed module id '${id}' (the on-disk file '${stripped}' ` +
131
+ "with a '?'/'#' suffix) -- stasis can't persist suffixes; disable the plugin minting it " +
132
+ 'or import the file directly'
133
+ )
134
+ }
135
+ }
136
+
137
+ // True when `id` is an on-disk file; false defers a virtual id to its owning plugin. The
138
+ // dangerous middle case -- a suffixed variant of a real file -- refuses instead (see above).
139
+ #existsOrRefuseSuffixed(id) {
140
+ if (existsSync(id)) return true
141
+ this.#refuseSuffixedOnDisk(id)
142
+ return false
143
+ }
144
+
145
+ #classifyOrThrow(id) {
146
+ const kind = classifyExtension(id, this.#resources)
147
+ if (kind === 'unknown') {
148
+ throw new Error(
149
+ `StasisRollup: unsupported extension for '${id}' -- add its extension or filename to the plugin's resources option or stop importing it`
150
+ )
151
+ }
152
+ return kind
153
+ }
154
+
155
+ #inRoot(path) {
156
+ try {
157
+ this.#state.relative(path)
158
+ return true
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ async #resolveId(ctx, source, importer, opts) {
165
+ // Built-ins are never bundled or recorded as edges; hand them back to rollup (null) so the
166
+ // user's external config applies. SECURITY: never consult getImport for a builtin name, so a
167
+ // tampered bundle can't redirect `fs` to an arbitrary file.
168
+ if (isBuiltin(source)) return null
169
+ const { isEntry, attributes: attrs = {} } = opts
170
+
171
+ // Load mode: resolution comes from the bundle's import map, not rollup's resolver -- the file
172
+ // may not be on disk. Return the original absolute path so output bytes match a capture build.
173
+ if (this.#state.config.loadBundle) {
174
+ if (isEntry) {
175
+ // Entry-flagged resolutions cover real `input` entries, emitFile'd chunks, and -- because
176
+ // rollup defaults isEntry to !importer -- bare sibling probes. Anchor like rollup's own
177
+ // resolver: against the importer when one exists (emitFile passes it), else cwd (like the
178
+ // run loader). No assertEntry here: a probe is indistinguishable from an entry at this
179
+ // point, so the load hook asserts from the graph's authoritative isEntry instead -- and a
180
+ // probe that never loads is never asserted.
181
+ if (source.startsWith('file:')) return { id: fileURLToPath(source) }
182
+ const base = importer && isAbsolute(importer) && !importer.startsWith('\0')
183
+ ? dirname(importer)
184
+ : process.cwd()
185
+ return { id: resolvePath(base, source) }
186
+ }
187
+ // A missing, virtual, or out-of-root importer was never the bundle's responsibility -- defer to rollup.
188
+ if (!importer || importer.startsWith('\0') || !isAbsolute(importer)) return null
189
+ if (!this.#inRoot(importer)) return null
190
+ const parentURL = pathToFileURL(importer).toString()
191
+ this.#assertPersistableAttributes(attrs)
192
+ // Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
193
+ // plugin-load, both keyed under `* (with: ...)`.
194
+ let url, format
195
+ try {
196
+ ;({ url, format } = this.#state.getImport(parentURL, source, { importAttributes: attrs }))
197
+ } catch (err) {
198
+ // No attested edge -- at a successful capture that's an EXTERNAL (never recorded as an
199
+ // edge), so hand it back to rollup. File BYTES still fail closed at the load hook.
200
+ // A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
201
+ if (err?.code === 'ERR_MODULE_NOT_FOUND') return null
202
+ throw err
203
+ }
204
+ const path = fileURLToPath(url)
205
+ // Refuse a resource only when load mode would actually serve it: an out-of-scope workspace
206
+ // resource under node_modules scope comes from disk via the user's asset plugin, exactly
207
+ // like a capture build (the load hook defers it with the same scope gate).
208
+ if (Bundle.isResourceFormat(format) && (this.#state.config.full || this.#state.inNodeModules(url))) {
209
+ this.#refuseResourceLoad(path)
210
+ }
211
+ // KNOWN GAP: sibling resolution metadata (moduleSideEffects, syntheticNamedExports, meta)
212
+ // isn't persisted in the bundle, so load mode can't replay it -- tree-shaking of a module a
213
+ // sibling marked side-effect-free at capture may differ from the capture build's output.
214
+ return { id: path }
215
+ }
216
+
217
+ // Capture: run the remaining resolveId hooks plus rollup's default algorithm (skipSelf guards
218
+ // re-entry), record the observed edge, and pass the resolution through unchanged.
219
+ const resolution = await ctx.resolve(source, importer, { ...opts, skipSelf: true })
220
+ if (!resolution || resolution.external) return resolution
221
+ const { id } = resolution
222
+ // Skip virtual modules (\0-prefixed or otherwise not an on-disk file); a suffixed variant of
223
+ // an on-disk file refuses inside the check rather than slipping through unattested.
224
+ if (id.startsWith('\0') || !isAbsolute(id) || !this.#existsOrRefuseSuffixed(id)) return resolution
225
+ this.#classifyOrThrow(id)
226
+
227
+ // Record the edge for BOTH code and resource targets, but never for entries (matching the
228
+ // loader) and never for an out-of-root importer (not the project's graph; load mode defers
229
+ // those parents the same way). Entries are flagged from the graph in buildEnd instead of
230
+ // trusting opts.isEntry -- see the constructor comment.
231
+ if (!isEntry && importer && isAbsolute(importer) && !importer.startsWith('\0') && this.#inRoot(importer)) {
232
+ this.#assertPersistableAttributes(attrs)
233
+ if (!this.#inRoot(id)) {
234
+ throw new Error(
235
+ `StasisRollup: '${source}' imported from ${importer} resolves outside the project root and can't be attested: ${id}`
236
+ )
237
+ }
238
+ this.#state.addImport(pathToFileURL(importer).toString(), source, pathToFileURL(id).toString(), { importAttributes: attrs })
239
+ }
240
+ return resolution
241
+ }
242
+
243
+ async #load(ctx, id) {
244
+ // Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
245
+ // the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
246
+ // In scope = full (everything under state.root) or node_modules-only per scope; ids other
247
+ // plugins resolved land here too, so the gate can't rely on our own resolveId alone.
248
+ if (this.#state.config.loadBundle) {
249
+ if (id.startsWith('\0') || !isAbsolute(id)) return null
250
+ if (!this.#inRoot(id)) return null // out of the project root -- not ours
251
+ const url = pathToFileURL(id).toString()
252
+ // Entries are asserted here from the graph's authoritative isEntry (known before load runs):
253
+ // a sibling's bare resolve() probe carries isEntry too but never loads, so only genuine
254
+ // roots reach this gate.
255
+ if (this.#state.config.full && ctx.getModuleInfo(id)?.isEntry) this.#state.assertEntry(url)
256
+ if (!this.#state.config.full && !this.#state.inNodeModules(url)) return null
257
+ const { source, format } = this.#state.getFile(url)
258
+ if (Bundle.isResourceFormat(format)) this.#refuseResourceLoad(id)
259
+ // Code is stored as UTF-8 text, exactly what rollup's parser expects.
260
+ return source
261
+ }
262
+
263
+ // Capture. Skip virtual modules and classify the rest exactly like the resolve hook (a sibling
264
+ // resolver may have minted ids our resolveId never saw). One read doubles as the existence
265
+ // check: ENOENT defers -- unless the id is a suffixed variant of an on-disk file, which
266
+ // refuses -- and any other read error is real.
267
+ if (id.startsWith('\0') || !isAbsolute(id)) return null
268
+ let source
269
+ try {
270
+ source = await realReadFile(id)
271
+ } catch (err) {
272
+ if (err?.code !== 'ENOENT') throw err
273
+ this.#refuseSuffixedOnDisk(id)
274
+ return null
275
+ }
276
+ const kind = this.#classifyOrThrow(id)
277
+ if (!this.#inRoot(id)) {
278
+ throw new Error(`StasisRollup: module is outside the project root and can't be attested: ${id}`)
279
+ }
280
+ const url = pathToFileURL(id).toString()
281
+
282
+ if (kind === 'resource') {
283
+ // Mark as a resource (State picks 'resource' vs 'resource:base64' from bytes). Return null:
284
+ // loading it into the graph is the user's asset plugin's job.
285
+ if (!this.#seen.has(id)) {
286
+ this.#seen.add(id)
287
+ this.#state.addFile(url, { source, resource: true, reason: 'rollup' })
288
+ }
289
+ return null
290
+ }
291
+
292
+ // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
293
+ assert.ok(isUtf8(source), `StasisRollup: code-classified file has non-UTF-8 bytes: ${id}`)
294
+
295
+ if (!this.#seen.has(id)) {
296
+ this.#seen.add(id)
297
+ this.#state.addFile(url, { source, reason: 'rollup' })
298
+ }
299
+
300
+ // Return the attested bytes so the build consumes exactly what was recorded (transform hooks
301
+ // still run after, symmetrically with load mode).
302
+ return source.toString('utf8')
303
+ }
304
+
305
+ // Capture epilogue (see the constructor comment): flag real entries from the completed graph,
306
+ // then write standalone/sidecar States -- only on a clean build (a partial one would overwrite
307
+ // the user's good file). buildEnd, not closeBundle: the capture is complete once the graph is,
308
+ // and closeBundle only fires when the caller remembers bundle.close().
309
+ async #buildEnd(ctx, error) {
310
+ if (error) return
311
+ const entryIds = [...ctx.getModuleIds()].filter((id) => {
312
+ if (!this.#seen.has(id)) return false // virtual/external/unattested ids never joined the capture
313
+ const info = ctx.getModuleInfo(id)
314
+ return Boolean(info?.isEntry) && !info.isExternal
315
+ })
316
+ // Re-record each entry with the flag set: addFile re-verifies the bytes against disk, and a
317
+ // RESOURCE entry fails loudly here ('a resource can't be an entry'), matching StasisEsbuild,
318
+ // instead of silently writing an artifact whose entries list omits the build's real entry.
319
+ const sources = await Promise.all(entryIds.map((id) => realReadFile(id)))
320
+ for (const [i, id] of entryIds.entries()) {
321
+ const resource = classifyExtension(id, this.#resources) === 'resource'
322
+ this.#state.addFile(pathToFileURL(id).toString(), { source: sources[i], isEntry: true, resource, reason: 'rollup' })
323
+ }
324
+ // The preload writes itself (hooks.js); standalone/sidecar States are written here.
325
+ if (this.#state !== State.preload) this.#state.write()
326
+ }
327
+ }