@exodus/stasis-plugins 1.0.0-beta.15 → 1.0.0-beta.17

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,49 +1,54 @@
1
- // Plugin <-> preload coordination (resolvePluginState): decides which State a bundler plugin
2
- // drives, given its options and the ambient preload.
3
-
4
1
  import assert from 'node:assert/strict'
2
+ import { isUtf8 } from 'node:buffer'
5
3
 
6
- import { DEFAULT_LOCK, assertOptionsMatchConfig, validatePluginOptions } from '@exodus/stasis-core/config'
4
+ import { DEFAULT_LOCK, assertKnownKeys, assertOption } from '@exodus/stasis-core/config'
7
5
  import { ensureStateForLoader, isLoaderInstalled } from '@exodus/stasis-core/hooks'
8
6
  import { State } from '@exodus/stasis-core/state'
9
- import { extSetsEqual, parseResourcesOption } from '@exodus/stasis-core/util'
10
-
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.
20
- // 5. Same bundleFile (or unspecified) reuses the preload State.
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).
7
+ import { classifyExtension, extSetsEqual, parseResourcesOption } from '@exodus/stasis-core/util'
8
+
9
+ // The Config options a plugin takes; not `lockFile`, as the lockfile is always the preload's.
10
+ const PLUGIN_OPTION_KEYS = ['scope', 'lock', 'bundle', 'bundleFile', 'resourcesBundleFile', 'debug', 'childProcess', 'packageJSON', 'resources']
11
+
12
+ // Validated as Config validates them, without constructing one (which has side effects).
13
+ export function validatePluginOptions(label, options) {
14
+ assertKnownKeys(Object.keys(options), PLUGIN_OPTION_KEYS, label)
15
+ for (const key of PLUGIN_OPTION_KEYS) {
16
+ if (options[key] === undefined) continue
17
+ if (key === 'resources') parseResourcesOption(label, options.resources)
18
+ else assertOption(key, options[key])
19
+ }
20
+ }
21
+
22
+ // The active Config is authoritative; `resources`, a Set, is coordinated in resolvePluginState instead.
23
+ export function assertOptionsMatchConfig(config, options) {
24
+ try {
25
+ for (const key of PLUGIN_OPTION_KEYS) {
26
+ if (key !== 'resources' && options[key] !== undefined) assert.equal(config[key === 'bundle' ? 'bundleMode' : key], options[key])
27
+ }
28
+ } catch (cause) {
29
+ throw new Error(`Plugin options conflict with active stasis state: ${cause.message}`, { cause })
30
+ }
31
+ }
32
+
33
+ // The State a bundler plugin runs against, given its options and the ambient preload: { state: null, isNoop: true }
34
+ // (inert: register no hooks), else { state, isNoop: false }. The lockfile is always the preload's; only the bundle
35
+ // may split off, to a sidecar State at its own bundleFile.
23
36
  export function resolvePluginState(label, options, cwd) {
24
37
  validatePluginOptions(label, options)
25
38
  const { lock, bundle, bundleFile, resourcesBundleFile, 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.
39
+ // The loader is installed but its lazy init hasn't fired (webpack builds plugins before user code). Never for a
40
+ // separate stasis-core copy: that would mint a phantom State.preload racing the real loader's write.
29
41
  if (!State.preload && isLoaderInstalled()) ensureStateForLoader()
30
42
  const ambient = State.preload
31
43
 
32
44
  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.
45
+ // `stasis run` always exports EXODUS_STASIS_LOCK, whichever stasis-core copy hosts this code.
35
46
  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) {
47
+ // Inert in a plain build, as where both are 'none' (a framework plugin switched off by env).
48
+ if ((!underStasisRun && lock === undefined && bundle === undefined) || (lock === 'none' && bundle === 'none')) {
40
49
  return { state: null, isNoop: true }
41
50
  }
42
-
43
- if (lock === 'none' && bundle === 'none') return { state: null, isNoop: true } // Rule 7
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.
51
+ // A partial opt-out would otherwise meet a misleading throw; an active bundle with lock='none' is legitimate.
47
52
  if ((lock === 'none' && bundle === undefined) || (bundle === 'none' && lock === undefined)) {
48
53
  throw new Error(
49
54
  `${label}: to opt out of stasis without a preload, pass BOTH lock='none' and bundle='none' ` +
@@ -51,14 +56,10 @@ export function resolvePluginState(label, options, cwd) {
51
56
  `bundle=${bundle === undefined ? 'default' : `'${bundle}'`})`
52
57
  )
53
58
  }
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.
59
+ // A writing or verifying lock would miss the bundler's own deps without a preload, but under `stasis run`.
57
60
  const effectiveLock = lock ?? DEFAULT_LOCK
58
61
  if (!underStasisRun && effectiveLock !== 'none' && effectiveLock !== 'ignore') {
59
- const phrasing = lock === undefined
60
- ? `the default lockfile mode ('${DEFAULT_LOCK}')`
61
- : `lockfile mode '${lock}'`
62
+ const phrasing = lock === undefined ? `the default lockfile mode ('${DEFAULT_LOCK}')` : `lockfile mode '${lock}'`
62
63
  throw new Error(
63
64
  `${label}: ${phrasing} requires a stasis preload; ` +
64
65
  `pass lock='none' or lock='ignore' to opt out, or run under the stasis loader`
@@ -67,18 +68,15 @@ export function resolvePluginState(label, options, cwd) {
67
68
  return { state: new State(cwd, options), isNoop: false }
68
69
  }
69
70
 
70
- // Preload active: the lockfile is necessarily the ambient's. Enforce field coordination, then reuse or sidecar.
71
+ // These must agree with the preload, which a reuse adopts and a sidecar copies.
71
72
  const pc = ambient.config
72
- // lock/scope/debug/childProcess/packageJSON must agree with the preload (reuse adopts it, sidecar
73
- // copies it). The string modes are quoted, the boolean toggles not (validatePluginOptions typed them).
74
73
  const shown = (value) => (typeof value === 'string' ? `'${value}'` : value)
75
74
  for (const key of ['lock', 'scope', 'debug', 'childProcess', 'packageJSON']) {
76
75
  if (options[key] !== undefined && options[key] !== pc[key]) {
77
76
  throw new Error(`${label}: ${key}=${shown(options[key])} conflicts with active preload ${key}=${shown(pc[key])}`)
78
77
  }
79
78
  }
80
- // resources is process-wide, so a plugin's allowlist must agree with the preload's -- compare
81
- // parsed sets (order/dupes/case-insensitive) to prevent one plugin silently widening the asset set.
79
+ // resources is process-wide: compared as parsed sets, so that no plugin silently widens the asset set.
82
80
  if (resources !== undefined) {
83
81
  const parsed = parseResourcesOption(label, resources)
84
82
  if (!extSetsEqual(parsed, pc.resources)) {
@@ -88,74 +86,70 @@ export function resolvePluginState(label, options, cwd) {
88
86
  }
89
87
  }
90
88
 
91
- // Mark the plugin attached so the preload's bundle `reason` map can distinguish files merely
92
- // fs-READ during the build from genuine run dependencies recorded before any plugin existed.
89
+ // So the preload's `reason` map can tell files merely fs-read during the build from the run's own dependencies.
93
90
  ambient.markPluginAttached()
94
91
 
95
- // A State of its own bundle (`mode`, at bundleFile) beside the preload, whose lockfile it shares
96
- // and whose other settings it carries over (sidecars skip stasis.config.json discovery).
97
- const sidecar = (mode) => ({
98
- state: new State(cwd, {
99
- parent: ambient,
100
- scope: pc.scope,
101
- lock: pc.lock,
102
- bundle: mode,
103
- bundleFile,
104
- resourcesBundleFile,
105
- debug: pc.debug,
106
- childProcess: pc.childProcess,
107
- packageJSON: pc.packageJSON,
108
- resources: [...pc.resources],
109
- brotliQuality: pc.brotliQuality,
110
- }),
111
- isNoop: false,
112
- })
92
+ // Sidecars skip stasis.config.json discovery, so carry the preload's settings over.
93
+ const sidecar = (mode) => {
94
+ const inherited = Object.fromEntries(['scope', 'lock', 'debug', 'childProcess', 'packageJSON', 'brotliQuality'].map((key) => [key, pc[key]]))
95
+ const state = new State(cwd, { parent: ambient, ...inherited, bundle: mode, bundleFile, resourcesBundleFile, resources: [...pc.resources] })
96
+ return { state, isNoop: false }
97
+ }
113
98
 
114
99
  if (pc.bundle) {
115
- // Preload has bundle on -- plugin can't disable (pc.bundleMode is neither 'none' nor 'ignore'), must match mode.
116
100
  if (bundle !== undefined && bundle !== pc.bundleMode) {
117
- throw new Error(
118
- `${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`
119
- )
120
- }
121
- if (bundleFile === undefined || bundleFile === pc.bundleFile) {
122
- // Rule 5: same bundleFile (or unspecified) reuses the preload.
123
- assertOptionsMatchConfig(pc, options)
124
- return { state: ambient, isNoop: false }
101
+ throw new Error(`${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`)
125
102
  }
126
- // Rule 6: different path -- sidecar inherits preload's modes, overrides bundleFile.
127
- return sidecar(pc.bundleMode)
103
+ if (bundleFile !== undefined && bundleFile !== pc.bundleFile) return sidecar(pc.bundleMode)
104
+ assertOptionsMatchConfig(pc, options)
105
+ return { state: ambient, isNoop: false }
128
106
  }
129
107
 
130
- // Preload exists but has no bundle.
131
108
  if (bundle === undefined || bundle === 'none' || bundle === 'ignore') {
132
- // Plugin wants no bundle either: reuse preload for lockfile coverage. bundle='ignore' vs the
133
- // preload's 'none' both mean "no plugin bundle", so strip the bundle field from the cross-check;
134
- // the assert pins that strip to today's no-bundle values so a future mode can't silently mask a disagreement.
109
+ // 'ignore' and the preload's 'none' both mean no bundle, so the field leaves the cross-check; the assert pins
110
+ // that to today's no-bundle modes, so that a future one can't silently mask a disagreement.
135
111
  assert.ok(pc.bundleMode === 'none' || pc.bundleMode === 'ignore',
136
112
  `unexpected preload bundleMode='${pc.bundleMode}' in the no-bundle branch`)
137
113
  const { bundle: _b, ...rest } = options
138
114
  assertOptionsMatchConfig(pc, rest)
139
115
  return { state: ambient, isNoop: false }
140
116
  }
141
- // Plugin wants its own bundle alongside preload's lockfile -- sidecar.
142
117
  if (!bundleFile) {
143
- throw new Error(
144
- `${label}: bundle='${bundle}' alongside a preload without bundle requires an explicit bundleFile`
145
- )
118
+ throw new Error(`${label}: bundle='${bundle}' alongside a preload without bundle requires an explicit bundleFile`)
146
119
  }
147
120
  return sidecar(bundle)
148
121
  }
149
122
 
150
- // What a plugin (`label`) says of a file it is given (`what`, as it names it) whose extension it
151
- // neither bundles as code nor is allowed as a resource.
123
+ // A State the caller owns (`stasis build`'s), else resolvePluginState's; a foreign copy's State fails the instanceof.
124
+ export const ownOrResolvedState = (label, options) =>
125
+ (options instanceof State ? options : resolvePluginState(label, options, process.cwd()).state)
126
+
127
+ // Dedupe is by path, so a rebuild with new bytes would emit them while the bundle and lockfile keep the old ones.
128
+ export const rebuildRefused = (label) =>
129
+ `${label}: watch/rebuild is not supported for capture -- run a one-shot build (rebuilds would silently attest stale content)`
130
+
152
131
  export const unsupportedExtension = (label, what) =>
153
132
  `${label}: unsupported extension for ${what} -- add its extension or filename to the plugin's resources option or stop importing it`
154
133
 
155
- // `state.getImport(parentURL, specifier, options)`, or undefined where the bundle attests no such edge:
156
- // at a successful capture that's an EXTERNAL (never recorded as an edge), the bundler's to resolve as
157
- // without us. File BYTES still fail closed where the plugin loads them; any other error is a real
158
- // fault, thrown.
134
+ // A file's kind (code|resource), or a throw naming it as `what` where it's neither.
135
+ export function classifyOrThrow(label, path, resources, what = `'${path}'`) {
136
+ const kind = classifyExtension(path, resources)
137
+ if (kind === 'unknown') throw new Error(unsupportedExtension(label, what))
138
+ return kind
139
+ }
140
+
141
+ // Refuse non-UTF-8 code rather than silently encode it as a resource.
142
+ export function assertCodeUtf8(label, path, source) {
143
+ assert.ok(isUtf8(source), `${label}: code-classified file has non-UTF-8 bytes: ${path}`)
144
+ }
145
+
146
+ // Only the `type` import attribute persists to the lockfile.
147
+ export function assertPersistableAttributes(attrs) {
148
+ for (const k of Object.keys(attrs)) assert.equal(k, 'type', `unsupported import attribute: ${k}`)
149
+ }
150
+
151
+ // `state.getImport(...)`, or undefined where the bundle attests no such edge: an EXTERNAL at capture, which the
152
+ // bundler resolves as without us (file BYTES still fail closed where the plugin loads them).
159
153
  export function attestedImport(state, parentURL, specifier, options) {
160
154
  try {
161
155
  return state.getImport(parentURL, specifier, options)
package/src/rollup.js CHANGED
@@ -1,65 +1,50 @@
1
1
  import assert from 'node:assert/strict'
2
- import { isUtf8 } from 'node:buffer'
3
2
  import { existsSync } from 'node:fs'
4
3
  import { isBuiltin } from 'node:module'
5
4
  import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'
6
5
  import { fileURLToPath, pathToFileURL } from 'node:url'
7
6
 
8
7
  import { Bundle } from '@exodus/stasis-core/bundle'
9
- import { attestedImport, resolvePluginState, unsupportedExtension } from './plugins.js'
8
+ import { assertCodeUtf8, assertPersistableAttributes, attestedImport, classifyOrThrow, ownOrResolvedState, rebuildRefused } from './plugins.js'
10
9
  import { State } from '@exodus/stasis-core/state'
11
10
  // Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
12
11
  // this plugin loads after, so a direct import would route rollup's reads through the capture hook.
13
12
  import { inScope, realReadFile } from '@exodus/stasis-core/state-util'
14
13
  import { classifyExtension, isPathWithin } from '@exodus/stasis-core/util'
15
14
 
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).
15
+ // An on-disk file by its absolute path, not a sibling plugin's virtual module (\0-prefixed or relative) or no importer.
16
+ const isDiskId = (id) => Boolean(id) && isAbsolute(id) && !id.startsWith('\0')
17
+
18
+ // Rollup chains resolveId/load hooks first-non-null-wins, so this plugin goes FIRST in `plugins`: listed later, a
19
+ // sibling resolver answers before stasis sees the edge (file BYTES still fail closed at the load hook).
20
20
  export class StasisRollup {
21
21
  #seen = new Set()
22
22
  #state
23
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
24
  #captureBuildCount = 0
28
25
 
29
26
  name = 'stasis'
30
27
 
31
28
  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.
29
+ // Transforms run as ordinary rollup plugins after this one: refuse a port's esbuild-style bag, not drop it.
34
30
  assert.equal(unsupported, undefined,
35
31
  '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
32
+ const state = ownOrResolvedState('StasisRollup', options)
41
33
  this.#state = state
42
- // Cache the resolved resources Set for the per-file classify hot path.
43
34
  this.#resources = state?.config.resources ?? new Set()
44
- if (!state) return // noop plugin: no hooks assigned, rollup sees only `name`
35
+ if (!state) return // inert: rollup sees only `name`
45
36
 
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.
37
+ // Rollup binds `this` to its PluginContext, so the instance arrives via the closure.
49
38
  const plugin = this
50
- this.resolveId = async function (source, importer, opts) {
39
+ this.resolveId = function (source, importer, opts) {
51
40
  return plugin.#resolveId(this, source, importer, opts)
52
41
  }
53
- this.load = async function (id) {
42
+ this.load = function (id) {
54
43
  return plugin.#load(this, id)
55
44
  }
56
45
 
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.
46
+ // A warm `cache:` object reuses modules and resolutions WITHOUT calling resolveId/load, so they'd go unattested
47
+ // at capture and unverified at load. Rollup still builds a fresh cache for this run.
63
48
  this.options = (inputOptions) => {
64
49
  if (typeof inputOptions.cache !== 'object' || inputOptions.cache === null) return null
65
50
  console.warn(
@@ -71,34 +56,18 @@ export class StasisRollup {
71
56
  }
72
57
 
73
58
  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.
59
+ // Watch rebuilds, and a second rollup() call reusing this instance, re-fire buildStart.
78
60
  this.buildStart = () => {
79
61
  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
- )
62
+ if (plugin.#captureBuildCount > 1) throw new Error(rebuildRefused('StasisRollup'))
84
63
  }
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
64
  this.buildEnd = function (error) {
93
65
  return plugin.#buildEnd(this, error)
94
66
  }
95
67
  }
96
68
  }
97
69
 
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.
70
+ // Rollup asset plugins read their files from disk in their own load hook, with no seam for attested bytes.
102
71
  #refuseResourceLoad(path) {
103
72
  throw new Error(
104
73
  `StasisRollup: bundle=load can't serve resource '${path}' -- rollup asset plugins read ` +
@@ -107,22 +76,8 @@ export class StasisRollup {
107
76
  )
108
77
  }
109
78
 
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.
79
+ // A '?'/'#'-suffixed variant of an on-disk path ('./icon.svg?url') feeds the file's bytes to the build under a key
80
+ // that can't be persisted, so skipping it would leave a build input unattested. A wholly virtual id is skippable.
126
81
  #refuseSuffixedOnDisk(id) {
127
82
  const stripped = id.split(/[?#]/u, 1)[0]
128
83
  if (stripped !== id && existsSync(stripped)) {
@@ -134,89 +89,53 @@ export class StasisRollup {
134
89
  }
135
90
  }
136
91
 
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(unsupportedExtension('StasisRollup', `'${id}'`))
149
- }
150
- return kind
151
- }
152
-
153
92
  #inRoot(path) {
154
93
  return isPathWithin(this.#state.root, path)
155
94
  }
156
95
 
157
96
  async #resolveId(ctx, source, importer, opts) {
158
- // Built-ins are never bundled or recorded as edges; hand them back to rollup (null) so the
159
- // user's external config applies. SECURITY: never consult getImport for a builtin name, so a
160
- // tampered bundle can't redirect `fs` to an arbitrary file.
97
+ // Left to the user's external config. SECURITY: never consult getImport for a builtin, so a tampered bundle
98
+ // can't redirect `fs`.
161
99
  if (isBuiltin(source)) return null
162
100
  const { isEntry, attributes: attrs = {} } = opts
163
101
 
164
- // Load mode: resolution comes from the bundle's import map, not rollup's resolver -- the file
165
- // may not be on disk. Return the original absolute path so output bytes match a capture build.
102
+ // Load mode resolves through the bundle's import map: the file may not be on disk.
166
103
  if (this.#state.config.loadBundle) {
167
104
  if (isEntry) {
168
- // Entry-flagged resolutions cover real `input` entries, emitFile'd chunks, and -- because
169
- // rollup defaults isEntry to !importer -- bare sibling probes. Anchor like rollup's own
170
- // resolver: against the importer when one exists (emitFile passes it), else cwd (like the
171
- // run loader). No assertEntry here: a probe is indistinguishable from an entry at this
172
- // point, so the load hook asserts from the graph's authoritative isEntry instead -- and a
173
- // probe that never loads is never asserted.
105
+ // Rollup defaults isEntry to !importer, so this covers bare sibling probes too: the load hook asserts entries
106
+ // from the graph instead. Anchored as rollup's resolver does, else to cwd as the run loader is.
174
107
  if (source.startsWith('file:')) return { id: fileURLToPath(source) }
175
- const base = importer && isAbsolute(importer) && !importer.startsWith('\0')
176
- ? dirname(importer)
177
- : process.cwd()
108
+ const base = isDiskId(importer) ? dirname(importer) : process.cwd()
178
109
  return { id: resolvePath(base, source) }
179
110
  }
180
- // A missing, virtual, or out-of-root importer was never the bundle's responsibility -- defer to rollup.
181
- if (!importer || importer.startsWith('\0') || !isAbsolute(importer)) return null
182
- if (!this.#inRoot(importer)) return null
183
- const parentURL = pathToFileURL(importer).toString()
184
- this.#assertPersistableAttributes(attrs)
185
- // Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
186
- // plugin-load, both keyed under `* (with: ...)`.
187
- // No attested edge: rollup's. File BYTES still fail closed at the load hook.
188
- const attested = attestedImport(this.#state, parentURL, source, { importAttributes: attrs })
111
+ if (!isDiskId(importer) || !this.#inRoot(importer)) return null
112
+ assertPersistableAttributes(attrs)
113
+ const attested = attestedImport(this.#state, pathToFileURL(importer).toString(), source, { importAttributes: attrs })
189
114
  if (attested === undefined) return null
190
115
  const { url, format } = attested
191
116
  const path = fileURLToPath(url)
192
- // Refuse a resource only when load mode would actually serve it: an out-of-scope workspace
193
- // resource under node_modules scope comes from disk via the user's asset plugin, exactly
194
- // like a capture build (the load hook defers it with the same scope gate).
195
- if (Bundle.isResourceFormat(format) && inScope(this.#state, url)) {
196
- this.#refuseResourceLoad(path)
197
- }
198
- // KNOWN GAP: sibling resolution metadata (moduleSideEffects, syntheticNamedExports, meta)
199
- // isn't persisted in the bundle, so load mode can't replay it -- tree-shaking of a module a
200
- // sibling marked side-effect-free at capture may differ from the capture build's output.
117
+ // An out-of-scope resource comes from disk through the user's asset plugin, as at capture.
118
+ if (Bundle.isResourceFormat(format) && inScope(this.#state, url)) this.#refuseResourceLoad(path)
119
+ // KNOWN GAP: a sibling's resolution metadata (moduleSideEffects, syntheticNamedExports, meta) isn't in the
120
+ // bundle, so tree-shaking may differ from the capture build's.
201
121
  return { id: path }
202
122
  }
203
123
 
204
- // Capture: run the remaining resolveId hooks plus rollup's default algorithm (skipSelf guards
205
- // re-entry), record the observed edge, and pass the resolution through unchanged.
124
+ // Capture: the remaining resolveId hooks and rollup's own algorithm, passed through with the edge recorded.
206
125
  const resolution = await ctx.resolve(source, importer, { ...opts, skipSelf: true })
207
126
  if (!resolution || resolution.external) return resolution
208
127
  const { id } = resolution
209
- // Skip virtual modules (\0-prefixed or otherwise not an on-disk file); a suffixed variant of
210
- // an on-disk file refuses inside the check rather than slipping through unattested.
211
- if (id.startsWith('\0') || !isAbsolute(id) || !this.#existsOrRefuseSuffixed(id)) return resolution
212
- this.#classifyOrThrow(id)
128
+ if (!isDiskId(id)) return resolution
129
+ if (!existsSync(id)) {
130
+ this.#refuseSuffixedOnDisk(id)
131
+ return resolution
132
+ }
133
+ classifyOrThrow('StasisRollup', id, this.#resources)
213
134
 
214
- // Record the edge for BOTH code and resource targets, but never for entries (matching the
215
- // loader) and never for an out-of-root importer (not the project's graph; load mode defers
216
- // those parents the same way). Entries are flagged from the graph in buildEnd instead of
217
- // trusting opts.isEntry -- see the constructor comment.
218
- if (!isEntry && importer && isAbsolute(importer) && !importer.startsWith('\0') && this.#inRoot(importer)) {
219
- this.#assertPersistableAttributes(attrs)
135
+ // Code and resource targets alike, but never for an entry, flagged from the graph in buildEnd (opts.isEntry is
136
+ // a sibling's probe too), nor an out-of-root importer, which load mode defers too.
137
+ if (!isEntry && isDiskId(importer) && this.#inRoot(importer)) {
138
+ assertPersistableAttributes(attrs)
220
139
  if (!this.#inRoot(id)) {
221
140
  throw new Error(
222
141
  `StasisRollup: '${source}' imported from ${importer} resolves outside the project root and can't be attested: ${id}`
@@ -228,30 +147,21 @@ export class StasisRollup {
228
147
  }
229
148
 
230
149
  async #load(ctx, id) {
231
- // Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
232
- // the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
233
- // In scope = full (everything under state.root) or node_modules-only per scope; ids other
234
- // plugins resolved land here too, so the gate can't rely on our own resolveId alone.
150
+ // getFile verifies the hash and throws on a file the bundle doesn't carry: no disk fallback. Ids other plugins
151
+ // resolved land here too.
235
152
  if (this.#state.config.loadBundle) {
236
- if (id.startsWith('\0') || !isAbsolute(id)) return null
237
- if (!this.#inRoot(id)) return null // out of the project root -- not ours
153
+ if (!isDiskId(id) || !this.#inRoot(id)) return null
238
154
  const url = pathToFileURL(id).toString()
239
- // Entries are asserted here from the graph's authoritative isEntry (known before load runs):
240
- // a sibling's bare resolve() probe carries isEntry too but never loads, so only genuine
241
- // roots reach this gate.
155
+ // The graph's isEntry: a sibling's resolve() probe carries isEntry too but never loads.
242
156
  if (this.#state.config.full && ctx.getModuleInfo(id)?.isEntry) this.#state.assertEntry(url)
243
157
  if (!inScope(this.#state, url)) return null
244
158
  const { source, format } = this.#state.getFile(url)
245
159
  if (Bundle.isResourceFormat(format)) this.#refuseResourceLoad(id)
246
- // Code is stored as UTF-8 text, exactly what rollup's parser expects.
247
160
  return source
248
161
  }
249
162
 
250
- // Capture. Skip virtual modules and classify the rest exactly like the resolve hook (a sibling
251
- // resolver may have minted ids our resolveId never saw). One read doubles as the existence
252
- // check: ENOENT defers -- unless the id is a suffixed variant of an on-disk file, which
253
- // refuses -- and any other read error is real.
254
- if (id.startsWith('\0') || !isAbsolute(id)) return null
163
+ // A sibling resolver may have minted ids our resolveId never saw. The read is the existence check.
164
+ if (!isDiskId(id)) return null
255
165
  let source
256
166
  try {
257
167
  source = await realReadFile(id)
@@ -260,55 +170,34 @@ export class StasisRollup {
260
170
  this.#refuseSuffixedOnDisk(id)
261
171
  return null
262
172
  }
263
- const kind = this.#classifyOrThrow(id)
173
+ const resource = classifyOrThrow('StasisRollup', id, this.#resources) === 'resource'
264
174
  if (!this.#inRoot(id)) {
265
175
  throw new Error(`StasisRollup: module is outside the project root and can't be attested: ${id}`)
266
176
  }
267
- const url = pathToFileURL(id).toString()
268
-
269
- if (kind === 'resource') {
270
- // Mark as a resource (State picks 'resource' vs 'resource:base64' from bytes). Return null:
271
- // loading it into the graph is the user's asset plugin's job.
272
- if (!this.#seen.has(id)) {
273
- this.#seen.add(id)
274
- this.#state.addFile(url, { source, resource: true, reason: 'rollup' })
275
- }
276
- return null
277
- }
278
-
279
- // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
280
- assert.ok(isUtf8(source), `StasisRollup: code-classified file has non-UTF-8 bytes: ${id}`)
281
-
177
+ if (!resource) assertCodeUtf8('StasisRollup', id, source)
282
178
  if (!this.#seen.has(id)) {
283
179
  this.#seen.add(id)
284
- this.#state.addFile(url, { source, reason: 'rollup' })
180
+ this.#state.addFile(pathToFileURL(id).toString(), { source, resource, reason: 'rollup' })
285
181
  }
286
-
287
- // Return the attested bytes so the build consumes exactly what was recorded (transform hooks
288
- // still run after, symmetrically with load mode).
289
- return source.toString('utf8')
182
+ // A resource is the user's asset plugin's to load; code is served as recorded (transform hooks still run after).
183
+ return resource ? null : source.toString('utf8')
290
184
  }
291
185
 
292
- // Capture epilogue (see the constructor comment): flag real entries from the completed graph,
293
- // then write standalone/sidecar States -- only on a clean build (a partial one would overwrite
294
- // the user's good file). buildEnd, not closeBundle: the capture is complete once the graph is,
295
- // and closeBundle only fires when the caller remembers bundle.close().
186
+ // Flags the graph's real entries (resolveId's opts.isEntry would take a sibling's bare probe for one), then writes
187
+ // a standalone or sidecar State on a clean build. buildEnd, not closeBundle, which only bundle.close() fires.
296
188
  async #buildEnd(ctx, error) {
297
189
  if (error) return
298
190
  const entryIds = [...ctx.getModuleIds()].filter((id) => {
299
- if (!this.#seen.has(id)) return false // virtual/external/unattested ids never joined the capture
191
+ if (!this.#seen.has(id)) return false
300
192
  const info = ctx.getModuleInfo(id)
301
193
  return Boolean(info?.isEntry) && !info.isExternal
302
194
  })
303
- // Re-record each entry with the flag set: addFile re-verifies the bytes against disk, and a
304
- // RESOURCE entry fails loudly here ('a resource can't be an entry'), matching StasisEsbuild,
305
- // instead of silently writing an artifact whose entries list omits the build's real entry.
195
+ // addFile re-verifies the bytes, and refuses a RESOURCE entry, as StasisEsbuild does.
306
196
  const sources = await Promise.all(entryIds.map((id) => realReadFile(id)))
307
197
  for (const [i, id] of entryIds.entries()) {
308
198
  const resource = classifyExtension(id, this.#resources) === 'resource'
309
199
  this.#state.addFile(pathToFileURL(id).toString(), { source: sources[i], isEntry: true, resource, reason: 'rollup' })
310
200
  }
311
- // The preload writes itself (hooks.js); standalone/sidecar States are written here.
312
201
  if (this.#state !== State.preload) this.#state.write()
313
202
  }
314
203
  }