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