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