@exodus/stasis-plugins 1.0.0-beta.9 → 1.0.0-rc.0

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/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 { resolvePluginState } 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
- import { realReadFile } from '@exodus/stasis-core/state-util'
14
- import { classifyExtension } from '@exodus/stasis-core/util'
12
+ import { inScope, realReadFile } from '@exodus/stasis-core/state-util'
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,102 +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(
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
92
  #inRoot(path) {
156
- try {
157
- this.#state.relative(path)
158
- return true
159
- } catch {
160
- return false
161
- }
93
+ return isPathWithin(this.#state.root, path)
162
94
  }
163
95
 
164
96
  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.
97
+ // Left to the user's external config. SECURITY: never consult getImport for a builtin, so a tampered bundle
98
+ // can't redirect `fs`.
168
99
  if (isBuiltin(source)) return null
169
100
  const { isEntry, attributes: attrs = {} } = opts
170
101
 
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.
102
+ // Load mode resolves through the bundle's import map: the file may not be on disk.
173
103
  if (this.#state.config.loadBundle) {
174
104
  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.
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.
181
107
  if (source.startsWith('file:')) return { id: fileURLToPath(source) }
182
- const base = importer && isAbsolute(importer) && !importer.startsWith('\0')
183
- ? dirname(importer)
184
- : process.cwd()
108
+ const base = isDiskId(importer) ? dirname(importer) : process.cwd()
185
109
  return { id: resolvePath(base, source) }
186
110
  }
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
- }
111
+ if (!isDiskId(importer) || !this.#inRoot(importer)) return null
112
+ assertPersistableAttributes(attrs)
113
+ const attested = attestedImport(this.#state, pathToFileURL(importer).toString(), source, { importAttributes: attrs })
114
+ if (attested === undefined) return null
115
+ const { url, format } = attested
204
116
  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.
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.
214
121
  return { id: path }
215
122
  }
216
123
 
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.
124
+ // Capture: the remaining resolveId hooks and rollup's own algorithm, passed through with the edge recorded.
219
125
  const resolution = await ctx.resolve(source, importer, { ...opts, skipSelf: true })
220
126
  if (!resolution || resolution.external) return resolution
221
127
  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)
128
+ if (!isDiskId(id)) return resolution
129
+ if (!existsSync(id)) {
130
+ this.#refuseSuffixedOnDisk(id)
131
+ return resolution
132
+ }
133
+ classifyOrThrow('StasisRollup', id, this.#resources)
226
134
 
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)
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)
233
139
  if (!this.#inRoot(id)) {
234
140
  throw new Error(
235
141
  `StasisRollup: '${source}' imported from ${importer} resolves outside the project root and can't be attested: ${id}`
@@ -241,30 +147,21 @@ export class StasisRollup {
241
147
  }
242
148
 
243
149
  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.
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.
248
152
  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
153
+ if (!isDiskId(id) || !this.#inRoot(id)) return null
251
154
  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.
155
+ // The graph's isEntry: a sibling's resolve() probe carries isEntry too but never loads.
255
156
  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
157
+ if (!inScope(this.#state, url)) return null
257
158
  const { source, format } = this.#state.getFile(url)
258
159
  if (Bundle.isResourceFormat(format)) this.#refuseResourceLoad(id)
259
- // Code is stored as UTF-8 text, exactly what rollup's parser expects.
260
160
  return source
261
161
  }
262
162
 
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
163
+ // A sibling resolver may have minted ids our resolveId never saw. The read is the existence check.
164
+ if (!isDiskId(id)) return null
268
165
  let source
269
166
  try {
270
167
  source = await realReadFile(id)
@@ -273,55 +170,34 @@ export class StasisRollup {
273
170
  this.#refuseSuffixedOnDisk(id)
274
171
  return null
275
172
  }
276
- const kind = this.#classifyOrThrow(id)
173
+ const resource = classifyOrThrow('StasisRollup', id, this.#resources) === 'resource'
277
174
  if (!this.#inRoot(id)) {
278
175
  throw new Error(`StasisRollup: module is outside the project root and can't be attested: ${id}`)
279
176
  }
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
-
177
+ if (!resource) assertCodeUtf8('StasisRollup', id, source)
295
178
  if (!this.#seen.has(id)) {
296
179
  this.#seen.add(id)
297
- this.#state.addFile(url, { source, reason: 'rollup' })
180
+ this.#state.addFile(pathToFileURL(id).toString(), { source, resource, reason: 'rollup' })
298
181
  }
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')
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')
303
184
  }
304
185
 
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().
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.
309
188
  async #buildEnd(ctx, error) {
310
189
  if (error) return
311
190
  const entryIds = [...ctx.getModuleIds()].filter((id) => {
312
- if (!this.#seen.has(id)) return false // virtual/external/unattested ids never joined the capture
191
+ if (!this.#seen.has(id)) return false
313
192
  const info = ctx.getModuleInfo(id)
314
193
  return Boolean(info?.isEntry) && !info.isExternal
315
194
  })
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.
195
+ // addFile re-verifies the bytes, and refuses a RESOURCE entry, as StasisEsbuild does.
319
196
  const sources = await Promise.all(entryIds.map((id) => realReadFile(id)))
320
197
  for (const [i, id] of entryIds.entries()) {
321
198
  const resource = classifyExtension(id, this.#resources) === 'resource'
322
199
  this.#state.addFile(pathToFileURL(id).toString(), { source: sources[i], isEntry: true, resource, reason: 'rollup' })
323
200
  }
324
- // The preload writes itself (hooks.js); standalone/sidecar States are written here.
325
201
  if (this.#state !== State.preload) this.#state.write()
326
202
  }
327
203
  }