@exodus/stasis-plugins 1.0.0-beta.3 → 1.0.0-beta.6
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/README.md +1 -1
- package/package.json +8 -6
- 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.
|
|
4
|
-
"description": "Bundler plugins (webpack, esbuild, Metro) for @exodus/stasis-core",
|
|
3
|
+
"version": "1.0.0-beta.6",
|
|
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/
|
|
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/
|
|
32
|
+
"url": "https://github.com/PreventiveMeasures/stasis/issues"
|
|
31
33
|
},
|
|
32
|
-
"homepage": "https://github.com/
|
|
34
|
+
"homepage": "https://github.com/PreventiveMeasures/stasis#readme",
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"@exodus/stasis-core": "1.0.0-beta.
|
|
36
|
+
"@exodus/stasis-core": "1.0.0-beta.6"
|
|
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
|
+
}
|