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

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +8 -6
  3. package/src/rollup.js +327 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # <img src="/stasis/logo.svg" alt="" width="39" height="39" valign="bottom" /> `@exodus/stasis-plugins`
2
2
 
3
- Bundler plugins (webpack, esbuild, Metro) for `@exodus/stasis-core`.
3
+ Bundler plugins (webpack, esbuild, Rollup, Metro) for `@exodus/stasis-core`.
4
4
 
5
5
  See main package [GitHub](https://github.com/ExodusOSS/stasis/tree/main/stasis) or [npm](https://npmjs.com/package/@exodus/stasis) for full README.
6
6
 
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@exodus/stasis-plugins",
3
- "version": "1.0.0-beta.3",
4
- "description": "Bundler plugins (webpack, esbuild, Metro) for @exodus/stasis-core",
3
+ "version": "1.0.0-beta.5",
4
+ "description": "Bundler plugins (webpack, esbuild, Rollup, Metro) for @exodus/stasis-core",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./webpack": "./src/webpack.js",
8
8
  "./esbuild": "./src/esbuild.js",
9
+ "./rollup": "./src/rollup.js",
9
10
  "./metro": "./src/metro.js",
10
11
  "./metro-transformer": "./src/metro-transformer.js"
11
12
  },
@@ -14,6 +15,7 @@
14
15
  "src/metro.js",
15
16
  "src/metro-transformer.js",
16
17
  "src/plugins.js",
18
+ "src/rollup.js",
17
19
  "src/webpack.js"
18
20
  ],
19
21
  "engines": {
@@ -21,16 +23,16 @@
21
23
  },
22
24
  "repository": {
23
25
  "type": "git",
24
- "url": "git+https://github.com/ExodusOSS/stasis.git",
26
+ "url": "git+https://github.com/PreventiveMeasures/stasis.git",
25
27
  "directory": "stasis-plugins"
26
28
  },
27
29
  "author": "Exodus Movement, Inc.",
28
30
  "license": "MIT",
29
31
  "bugs": {
30
- "url": "https://github.com/ExodusOSS/stasis/issues"
32
+ "url": "https://github.com/PreventiveMeasures/stasis/issues"
31
33
  },
32
- "homepage": "https://github.com/ExodusOSS/stasis#readme",
34
+ "homepage": "https://github.com/PreventiveMeasures/stasis#readme",
33
35
  "dependencies": {
34
- "@exodus/stasis-core": "1.0.0-beta.3"
36
+ "@exodus/stasis-core": "1.0.0-beta.5"
35
37
  }
36
38
  }
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
+ }