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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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, assertOptionsMatchConfig, validatePluginOptions } from '@exodus/stasis-core/config'
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
- // 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.
20
- // 5. Same bundleFile (or unspecified) reuses the preload State.
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).
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
- 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.
38
+ const { lock, bundle, bundleFile, resourcesBundleFile, resources } = options
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
- // "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.
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
- // 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) {
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/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.
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,26 +68,15 @@ export function resolvePluginState(label, options, cwd) {
67
68
  return { state: new State(cwd, options), isNoop: false }
68
69
  }
69
70
 
70
- // Preload active: the lockfile is necessarily the ambient's. Enforce field coordination, then reuse or sidecar.
71
+ // These must agree with the preload, which a reuse adopts and a sidecar copies.
71
72
  const pc = ambient.config
72
- if (lock !== undefined && lock !== pc.lock) {
73
- throw new Error(`${label}: lock='${lock}' conflicts with active preload lock='${pc.lock}'`)
74
- }
75
- // scope/debug/childProcess must agree with the preload (reuse adopts it, sidecar copies it).
76
- if (scope !== undefined && scope !== pc.scope) {
77
- throw new Error(`${label}: scope='${scope}' conflicts with active preload scope='${pc.scope}'`)
78
- }
79
- if (debug !== undefined && debug !== pc.debug) {
80
- throw new Error(`${label}: debug=${debug} conflicts with active preload debug=${pc.debug}`)
81
- }
82
- if (childProcess !== undefined && childProcess !== pc.childProcess) {
83
- throw new Error(`${label}: childProcess=${childProcess} conflicts with active preload childProcess=${pc.childProcess}`)
84
- }
85
- if (packageJSON !== undefined && packageJSON !== pc.packageJSON) {
86
- throw new Error(`${label}: packageJSON=${packageJSON} conflicts with active preload packageJSON=${pc.packageJSON}`)
73
+ const shown = (value) => (typeof value === 'string' ? `'${value}'` : value)
74
+ for (const key of ['lock', 'scope', 'debug', 'childProcess', 'packageJSON']) {
75
+ if (options[key] !== undefined && options[key] !== pc[key]) {
76
+ throw new Error(`${label}: ${key}=${shown(options[key])} conflicts with active preload ${key}=${shown(pc[key])}`)
77
+ }
87
78
  }
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.
79
+ // resources is process-wide: compared as parsed sets, so that no plugin silently widens the asset set.
90
80
  if (resources !== undefined) {
91
81
  const parsed = parseResourcesOption(label, resources)
92
82
  if (!extSetsEqual(parsed, pc.resources)) {
@@ -96,74 +86,75 @@ export function resolvePluginState(label, options, cwd) {
96
86
  }
97
87
  }
98
88
 
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.
89
+ // So the preload's `reason` map can tell files merely fs-read during the build from the run's own dependencies.
101
90
  ambient.markPluginAttached()
102
91
 
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
+ }
98
+
103
99
  if (pc.bundle) {
104
- // Preload has bundle on -- plugin can't disable, must match mode.
105
- if (bundle === 'none' || bundle === 'ignore') {
106
- throw new Error(
107
- `${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`
108
- )
109
- }
110
100
  if (bundle !== undefined && bundle !== pc.bundleMode) {
111
- throw new Error(
112
- `${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`
113
- )
101
+ throw new Error(`${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`)
114
102
  }
115
- if (bundleFile === undefined || bundleFile === pc.bundleFile) {
116
- // Rule 5: same bundleFile (or unspecified) reuses the preload.
117
- assertOptionsMatchConfig(pc, options)
118
- return { state: ambient, isNoop: false }
119
- }
120
- // Rule 6: different path -- sidecar inherits preload's modes, overrides bundleFile.
121
- const sidecar = new State(cwd, {
122
- parent: ambient,
123
- scope: pc.scope,
124
- lock: pc.lock,
125
- bundle: pc.bundleMode,
126
- bundleFile,
127
- resourcesBundleFile,
128
- debug: pc.debug,
129
- childProcess: pc.childProcess,
130
- packageJSON: pc.packageJSON,
131
- resources: [...pc.resources],
132
- // carried over: sidecars skip stasis.config.json discovery
133
- brotliQuality: pc.brotliQuality,
134
- })
135
- return { state: sidecar, isNoop: false }
103
+ if (bundleFile !== undefined && bundleFile !== pc.bundleFile) return sidecar(pc.bundleMode)
104
+ assertOptionsMatchConfig(pc, options)
105
+ return { state: ambient, isNoop: false }
136
106
  }
137
107
 
138
- // Preload exists but has no bundle.
139
108
  if (bundle === undefined || bundle === 'none' || bundle === 'ignore') {
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.
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.
143
111
  assert.ok(pc.bundleMode === 'none' || pc.bundleMode === 'ignore',
144
112
  `unexpected preload bundleMode='${pc.bundleMode}' in the no-bundle branch`)
145
113
  const { bundle: _b, ...rest } = options
146
114
  assertOptionsMatchConfig(pc, rest)
147
115
  return { state: ambient, isNoop: false }
148
116
  }
149
- // Plugin wants its own bundle alongside preload's lockfile -- sidecar.
150
117
  if (!bundleFile) {
151
- throw new Error(
152
- `${label}: bundle='${bundle}' alongside a preload without bundle requires an explicit bundleFile`
153
- )
118
+ throw new Error(`${label}: bundle='${bundle}' alongside a preload without bundle requires an explicit bundleFile`)
119
+ }
120
+ return sidecar(bundle)
121
+ }
122
+
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
+
131
+ export const unsupportedExtension = (label, what) =>
132
+ `${label}: unsupported extension for ${what} -- add its extension or filename to the plugin's resources option or stop importing it`
133
+
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).
153
+ export function attestedImport(state, parentURL, specifier, options) {
154
+ try {
155
+ return state.getImport(parentURL, specifier, options)
156
+ } catch (err) {
157
+ if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
158
+ throw err
154
159
  }
155
- const sidecar = new State(cwd, {
156
- parent: ambient,
157
- scope: pc.scope,
158
- lock: pc.lock,
159
- bundle,
160
- bundleFile,
161
- resourcesBundleFile,
162
- debug: pc.debug,
163
- childProcess: pc.childProcess,
164
- packageJSON: pc.packageJSON,
165
- resources: [...pc.resources],
166
- brotliQuality: pc.brotliQuality, // carried over, like the Rule 6 sidecar above
167
- })
168
- return { state: sidecar, isNoop: false }
169
160
  }