@exodus/stasis-plugins 1.0.0-beta.1 → 1.0.0-beta.11

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 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.1",
4
- "description": "Bundler plugins (webpack, esbuild, Metro) for @exodus/stasis-core",
3
+ "version": "1.0.0-beta.11",
4
+ "description": "Bundler plugins (webpack, esbuild, Rollup, Metro) for @exodus/stasis-core",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./webpack": "./src/webpack.js",
8
8
  "./esbuild": "./src/esbuild.js",
9
+ "./rollup": "./src/rollup.js",
9
10
  "./metro": "./src/metro.js",
10
11
  "./metro-transformer": "./src/metro-transformer.js"
11
12
  },
@@ -14,6 +15,7 @@
14
15
  "src/metro.js",
15
16
  "src/metro-transformer.js",
16
17
  "src/plugins.js",
18
+ "src/rollup.js",
17
19
  "src/webpack.js"
18
20
  ],
19
21
  "engines": {
@@ -21,16 +23,16 @@
21
23
  },
22
24
  "repository": {
23
25
  "type": "git",
24
- "url": "git+https://github.com/ExodusOSS/stasis.git",
26
+ "url": "git+https://github.com/PreventiveMeasures/stasis.git",
25
27
  "directory": "stasis-plugins"
26
28
  },
27
29
  "author": "Exodus Movement, Inc.",
28
30
  "license": "MIT",
29
31
  "bugs": {
30
- "url": "https://github.com/ExodusOSS/stasis/issues"
32
+ "url": "https://github.com/PreventiveMeasures/stasis/issues"
31
33
  },
32
- "homepage": "https://github.com/ExodusOSS/stasis#readme",
34
+ "homepage": "https://github.com/PreventiveMeasures/stasis#readme",
33
35
  "dependencies": {
34
- "@exodus/stasis-core": "1.0.0-beta.1"
36
+ "@exodus/stasis-core": "1.0.0-beta.11"
35
37
  }
36
38
  }
package/src/esbuild.js CHANGED
@@ -1,50 +1,104 @@
1
1
  import { isUtf8 } from 'node:buffer'
2
- import { isBuiltin } from 'node:module'
3
- import { extname, resolve as resolvePath } from 'node:path'
2
+ import { dirname, extname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
4
3
  import { fileURLToPath, pathToFileURL } from 'node:url'
5
4
  import assert from 'node:assert/strict'
6
5
 
7
6
  import { Bundle } from '@exodus/stasis-core/bundle'
8
7
  import { resolvePluginState } from './plugins.js'
9
8
  import { State } from '@exodus/stasis-core/state'
10
- // Pre-patch snapshot, NOT `import { readFile } from 'node:fs/promises'`: under
11
- // `stasis run --fs=async` that builtin is monkey-patched, and this plugin loads after
12
- // the patch, so a direct import would route esbuild's own source reads through the
13
- // --fs capture hook. The snapshot in state-util (taken during the --import preload) is
14
- // the genuine reader.
9
+ // Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
10
+ // this plugin loads after, so a direct import would route esbuild's reads through the capture hook.
15
11
  import { realReadFile } from '@exodus/stasis-core/state-util'
16
- import { classifyExtension } from '@exodus/stasis-core/util'
12
+ import { EMPTY_MODULE_PATH, classifyExtension } from '@exodus/stasis-core/util'
13
+
14
+ // The build options esbuild's resolver reads, replayed into the disabled-import probe so it
15
+ // resolves exactly as the build does.
16
+ const RESOLVE_OPTIONS = ['absWorkingDir', 'alias', 'conditions', 'external', 'mainFields', 'nodePaths',
17
+ 'packages', 'platform', 'preserveSymlinks', 'resolveExtensions', 'tsconfig', 'tsconfigRaw']
18
+
19
+ // One import of `specifier` as `kind` would issue it, as the probe's stdin.
20
+ function probeSource(specifier, kind) {
21
+ const quoted = JSON.stringify(specifier)
22
+ switch (kind) {
23
+ case 'require-call':
24
+ case 'require-resolve':
25
+ return { contents: `require(${quoted})`, loader: 'js' }
26
+ case 'dynamic-import':
27
+ return { contents: `import(${quoted})`, loader: 'js' }
28
+ case 'import-rule':
29
+ case 'composes-from':
30
+ return { contents: `@import ${quoted};`, loader: 'css' }
31
+ case 'url-token':
32
+ return { contents: `a { b: url(${quoted}) }`, loader: 'css' }
33
+ default:
34
+ return { contents: `import ${quoted}`, loader: 'js' }
35
+ }
36
+ }
37
+
38
+ // A `browser` map key or a path to test against one, loosened past esbuild's matching (it tries a
39
+ // path bare, plus each resolve extension, plus `/index`, with or without './'): no './' or `.`
40
+ // segments, every extension stripped, trailing index segments dropped. Equal loose keys are a
41
+ // SUPERSET of esbuild's matches -- only a probe decides.
42
+ function looseBrowserKey(path) {
43
+ const parts = path.replaceAll('\\', '/').split('/').filter((part) => part !== '' && part !== '.')
44
+ while (parts.length > 0) {
45
+ const stem = parts.at(-1).replace(/\..*$/su, '')
46
+ if (stem !== 'index' || parts.length === 1) {
47
+ parts[parts.length - 1] = stem
48
+ break
49
+ }
50
+ parts.pop()
51
+ }
52
+ return parts.join('/')
53
+ }
54
+
55
+ // The loose keys a directory's package.json `browser` map sends to `false` (esbuild's "disabled").
56
+ async function browserFalseKeys(dir) {
57
+ let pkg
58
+ try {
59
+ pkg = JSON.parse(await realReadFile(join(dir, 'package.json'), 'utf8'))
60
+ } catch {
61
+ return new Set() // no (readable) package.json: no map
62
+ }
63
+ const browser = pkg?.browser
64
+ if (browser === null || typeof browser !== 'object' || Array.isArray(browser)) return new Set()
65
+ return new Set(Object.keys(browser).filter((key) => browser[key] === false).map((key) => looseBrowserKey(key)))
66
+ }
17
67
 
18
68
  export class StasisEsbuild {
19
69
  #seen = new Set()
20
70
  #state
21
71
  #resources
22
72
  #loaders
73
+ #transform
74
+ #emptyModule // URL of the empty module disabled imports point at, once capture recorded it
75
+ #browserScopes = new Map() // dir -> Promise<[{ dir, keys }]>: `false` keys of it and its ancestors
76
+ #probes = new Map() // [kind, resolveDir, specifier] -> Promise<disabled path | null>
23
77
 
24
- // Build starts observed by this (capture-mode) instance, across rebuilds AND across separate
25
- // esbuild.build()/context() calls sharing one instance. The second start is refused -- see
26
- // the onStart hook in setup().
78
+ // Build starts observed by this instance (across rebuilds and separate build()/context() calls);
79
+ // the second is refused in onStart.
27
80
  #captureBuildCount = 0
28
81
 
29
- constructor(options = {}, { loaders } = {}) {
30
- // A caller that owns a State (e.g. `stasis build`) can pass it directly to drive the plugin, bypassing resolvePluginState; mode follows that State's config, and a foreign-copy State fails closed via the instanceof miss.
82
+ constructor(options = {}, { loaders, transform } = {}) {
83
+ // A caller that owns a State (e.g. `stasis build`) can pass it directly; a foreign-copy State
84
+ // fails closed via the instanceof miss.
31
85
  const state = options instanceof State
32
86
  ? options
33
87
  : resolvePluginState('StasisEsbuild', options, process.cwd()).state
34
88
  this.#state = state // null when plugin should be inert
35
- // resources is a Config field now (validated + coordinated against any preload in
36
- // resolvePluginState); cache the resolved Set for the per-file classify hot path.
89
+ // Cache the resolved resources Set for the per-file classify hot path.
37
90
  this.#resources = state?.config.resources ?? new Set()
38
- // Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } so a .js file
39
- // carrying JSX -- the React Native convention -- parses; esbuild's default 'js'
40
- // loader rejects JSX). Keyed by extname WITH the dot; falls back to the
41
- // extension-derived loader (see #loaderFor) when an extension isn't overridden.
91
+ // Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } for RN's JSX-in-.js),
92
+ // keyed by extname with the dot; falls back to #loaderFor.
42
93
  this.#loaders = loaders ?? new Map()
94
+ // Load-mode-only code rewrite hook (e.g. `stasis build --platform=hermes` downleveling):
95
+ // (source, { path, loader }) => { contents, loader } | undefined, may be async. Never
96
+ // runs at capture -- capture attests the file's raw bytes.
97
+ this.#transform = transform
43
98
  }
44
99
 
45
- // The esbuild loader for a served file: an explicit per-extension override if the
46
- // caller configured one, else derived from the extension (.cjs/.mjs/.cts/.mts strip
47
- // their leading module letter to js/ts; other code extensions map 1:1 to a loader).
100
+ // esbuild loader for a served file: configured override, else derived from extension
101
+ // (.cjs/.mjs/.cts/.mts strip their module letter to js/ts).
48
102
  #loaderFor(path) {
49
103
  const ext = extname(path)
50
104
  return this.#loaders.get(ext) ?? ext.replace(/^\.[cm]?/u, '')
@@ -54,21 +108,80 @@ export class StasisEsbuild {
54
108
  return 'stasis'
55
109
  }
56
110
 
57
- setup = ({ onResolve, onLoad, onStart, onEnd, resolve }) => {
111
+ // Every directory from `dir` up whose package.json `browser` map disables something, nearest first.
112
+ // esbuild consults only the nearest `browser` map, so this is a superset.
113
+ #browserFalseScopes(dir) {
114
+ let scopes = this.#browserScopes.get(dir)
115
+ if (scopes === undefined) {
116
+ const parent = dirname(dir)
117
+ scopes = Promise.all([browserFalseKeys(dir), parent === dir ? [] : this.#browserFalseScopes(parent)])
118
+ .then(([keys, above]) => (keys.size > 0 ? [{ dir, keys }, ...above] : above))
119
+ this.#browserScopes.set(dir, scopes)
120
+ }
121
+ return scopes
122
+ }
123
+
124
+ // Whether some `browser` map `false` entry could match this resolution: the resolved path (and, for
125
+ // a relative specifier, the path it names) under each map's dir, or a bare specifier as is or as
126
+ // './<importer dir>/<specifier>' (a Browserify quirk esbuild keeps).
127
+ async #mayBeDisabled(path, specifier, resolveDir) {
128
+ const isPackagePath = !isAbsolute(specifier) && !/^\.\.?(?:\/|$)/u.test(specifier) && !specifier.startsWith('/')
129
+ const paths = isPackagePath ? [path] : [path, resolvePath(resolveDir, specifier)]
130
+ const dirs = new Set([resolveDir, ...paths.map((p) => dirname(p))])
131
+ const scopes = (await Promise.all([...dirs].map((dir) => this.#browserFalseScopes(dir)))).flat()
132
+ return scopes.some((scope) => {
133
+ const candidates = paths.map((p) => relative(scope.dir, p))
134
+ if (isPackagePath) candidates.push(specifier, join(relative(scope.dir, resolveDir), specifier))
135
+ return candidates.some((candidate) => scope.keys.has(looseBrowserKey(candidate)))
136
+ })
137
+ }
138
+
139
+ // esbuild's own verdict: a one-import build from the importer's dir with the build's resolve options
140
+ // and every load stubbed empty, whose metafile still names a disabled module `(disabled):<path>`.
141
+ // -> that path (absolute), or null when esbuild doesn't disable the import.
142
+ #probeDisabled(specifier, kind, resolveDir, { esbuild, initialOptions }) {
143
+ const key = JSON.stringify([kind, resolveDir, specifier])
144
+ let probe = this.#probes.get(key)
145
+ if (probe === undefined) {
146
+ const options = Object.fromEntries(RESOLVE_OPTIONS.filter((k) => initialOptions[k] !== undefined).map((k) => [k, initialOptions[k]]))
147
+ const cwd = initialOptions.absWorkingDir ?? process.cwd()
148
+ probe = esbuild.build({
149
+ ...options,
150
+ stdin: { ...probeSource(specifier, kind), resolveDir },
151
+ bundle: true,
152
+ write: false,
153
+ metafile: true,
154
+ logLevel: 'silent',
155
+ plugins: [{ name: 'stasis-probe', setup: (build) => build.onLoad({ filter: /$/ }, () => ({ contents: '', loader: 'empty' })) }],
156
+ }).then(({ metafile }) => {
157
+ const found = metafile.inputs['<stdin>']?.imports.find((i) => i.original === specifier)?.path
158
+ if (!found?.startsWith('(disabled):')) return null
159
+ return resolvePath(cwd, found.slice('(disabled):'.length))
160
+ }, () => null)
161
+ this.#probes.set(key, probe)
162
+ }
163
+ return probe
164
+ }
165
+
166
+ // A `browser` field mapping this import to `false` makes esbuild resolve it to an empty "disabled"
167
+ // module, but its resolve API drops that flag: a bare name comes back as a namespace-less path, a
168
+ // file as its plain path. The file case is confirmed by a probe, and only where a `false` entry
169
+ // could match (browser platform, see #mayBeDisabled).
170
+ async #isDisabled(res, specifier, { kind, resolveDir }, build) {
171
+ if (res.external) return false
172
+ if (res.namespace === '') return true // only a disabled bare name resolves to no namespace
173
+ if (res.namespace !== 'file' || (build.initialOptions.platform ?? 'browser') !== 'browser') return false
174
+ if (!(await this.#mayBeDisabled(res.path, specifier, resolveDir))) return false
175
+ // Compare the paths: a sibling plugin may have resolved the import to something else entirely.
176
+ return (await this.#probeDisabled(specifier, kind, resolveDir, build)) === res.path
177
+ }
178
+
179
+ setup = ({ onResolve, onLoad, onStart, onEnd, resolve, initialOptions, esbuild }) => {
58
180
  if (!this.#state) return // noop plugin
59
181
 
60
- // Watch/rebuild capture is EXPLICITLY UNSUPPORTED -- fail the second build loudly before it
61
- // captures anything. Capture's dedupe is keyed by PATH, not content (#seen below, plus
62
- // State.write's compare-and-skip caches), so on a rebuild where a file's bytes changed the
63
- // plugin would skip re-capture: the emitted output would use the new bytes while the
64
- // bundle/lockfile silently kept attesting the OLD ones. Rather than track content changes
65
- // across rebuilds, refuse them outright. esbuild rebuilds (watch mode, context.rebuild())
66
- // re-fire onStart/onEnd against the SAME setup registrations, so an instance-level counter
67
- // sees every start; it also covers reusing one plugin instance across two esbuild.build()
68
- // calls, which has the same staleness hazard. Returning errors from onStart is the
69
- // esbuild-idiomatic hard failure (the build reports them and onEnd's clean-build gate then
70
- // skips the write). Load mode registers nothing here: it serves immutable attested bytes,
71
- // so rebuilds have no drift hazard.
182
+ // Watch/rebuild capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
183
+ // changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Rebuilds re-fire
184
+ // onStart against the same registrations, so an instance counter catches the second and errors.
72
185
  if (!this.#state.config.loadBundle) {
73
186
  onStart(() => {
74
187
  this.#captureBuildCount += 1
@@ -81,10 +194,8 @@ export class StasisEsbuild {
81
194
  })
82
195
  }
83
196
 
84
- // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
85
- // Standalone and sidecar States have no such hook, so the plugin writes them when the
86
- // build finishes -- but only on a clean build, otherwise a partial bundle/lockfile
87
- // would overwrite the user's good file.
197
+ // The preload writes itself (hooks.js); standalone/sidecar States are written on build finish --
198
+ // only on a clean build (a partial one would overwrite the user's good file).
88
199
  if (this.#state !== State.preload) {
89
200
  onEnd((result) => {
90
201
  if (result.errors.length > 0) return
@@ -92,67 +203,40 @@ export class StasisEsbuild {
92
203
  })
93
204
  }
94
205
 
95
- // Load mode: resolution comes from the bundle's import map, not esbuild's
96
- // resolver. The on-disk file may not even exist -- the bundle is the source
97
- // of truth (mirrors `stasis run --bundle=load`; see stasis-core/src/hooks.js).
98
- // We keep `namespace: 'file'` with the original absolute path, even though the
99
- // file isn't on disk: esbuild stats the path only when its own default resolver
100
- // produced it; paths returned from a plugin's onResolve are trusted as-is. That
101
- // gives the same output bytes as a capture-mode build -- esbuild's path-banner
102
- // comments, asset names, and source maps all use the original paths.
206
+ // Load mode: resolution comes from the bundle's import map, not esbuild's resolver -- the file
207
+ // may not be on disk. Keep namespace:'file' with the original absolute path (esbuild doesn't stat
208
+ // plugin-returned paths) so output bytes -- banners, asset names, source maps -- match a capture build.
103
209
  onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
104
210
  if (!this.#state.config.loadBundle) return undefined
105
- // Node built-ins (`constants`, `node:fs`, ...) are never carried in the bundle
106
- // and never recorded as import edges -- the capture side (and the Node run
107
- // loader, stasis-core/hooks.js) defer isBuiltin() specifiers before addImport
108
- // sees them. Returning undefined hands the builtin back to esbuild's own
109
- // resolution, which externalizes it under platform:'node' exactly as in a build
110
- // without this plugin; state.getImport would instead throw ERR_MODULE_NOT_FOUND
111
- // ("Cannot find module 'constants' imported from <file>"). Mirrors the webpack
112
- // plugin's load-mode builtin guard.
113
- if (isBuiltin(specifier)) return undefined
114
211
  const isEntry = resolveKind === 'entry-point'
115
212
  let url
116
213
  if (isEntry) {
117
- // esbuild's entryPoints are typically absolute, but a bare/relative entry
118
- // is anchored to cwd just like the run loader does (resolvePath(cwd, ...))
119
- // so the entry file doesn't need to be on disk.
214
+ // Bare/relative entries are anchored to cwd (like the run loader), so the file needn't be on disk.
120
215
  url = specifier.startsWith('file:')
121
216
  ? specifier
122
217
  : pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
123
218
  if (this.#state.config.full) this.#state.assertEntry(url)
124
219
  } else {
125
220
  const parentURL = pathToFileURL(importer).toString()
126
- // Forward import attributes (e.g. `with { type: 'json' }`) so a
127
- // plugin-captured bundle round-trips to plugin-load symmetrically: both
128
- // sides key the edge under `* (with: ...)` (no conditions because the
129
- // plugin doesn't know them). KNOWN GAP: a bundle captured by the Node
130
- // run loader keys edges under specific conditions like `node, import,
131
- // ... (with: ...)`; the plugin's `*`-keyed lookup misses that, and the
132
- // wildcard fallback in state.getImport only falls back to `*` (without
133
- // attrs), so a Node-loader-captured bundle with attributed imports
134
- // won't reload via the esbuild plugin without further work
135
- // (state.getImport would need a multi-conditions match scan, or the
136
- // Node loader would need to mirror under '*' too).
221
+ // Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
222
+ // plugin-load, both keyed under `* (with: ...)`. KNOWN GAP: a Node-loader-captured bundle
223
+ // keys attributed edges under specific conditions the plugin's `*` lookup won't match.
224
+ // The edge is looked up even for a built-in's name: a browser build records `fs` when a
225
+ // `browser` field disables it (an edge to the empty module) or a package by that name serves it.
137
226
  try {
138
227
  ;({ url } = this.#state.getImport(parentURL, specifier, { importAttributes: attrs }))
139
228
  } catch (err) {
140
- // No attested edge. getImport tried the recorded edge under every conditions
141
- // bucket (resolveBundled), so a miss means the request names nothing the
142
- // bundle carries -- at a successful capture it was an EXTERNAL (a user
143
- // `external` entry; built-ins are handled above). Externals are never recorded
144
- // as edges, so return undefined to hand it back to esbuild, which externalizes it as it would
145
- // without this plugin. This relaxes only the resolve-time edge lookup:
146
- // a genuinely missing in-scope FILE still fails closed at onLoad
147
- // (state.getFile serves in-scope bytes from the bundle or throws), so no
148
- // disk fallback is introduced. A non-MODULE_NOT_FOUND error is a real
149
- // fault -- rethrow it.
229
+ // No attested edge -- at a successful capture that's a built-in or another EXTERNAL (never
230
+ // recorded as an edge), so hand it back to esbuild, which externalizes it as without us
231
+ // (built-ins under platform:'node'). File BYTES still fail closed at onLoad.
232
+ // A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
150
233
  if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
151
234
  throw err
152
235
  }
153
236
  }
154
237
  const format = this.#state.getFormat(url)
155
- const kind = Bundle.isResourceFormat(format) ? 'resource' : 'code'
238
+ const isEmpty = url === pathToFileURL(resolvePath(this.#state.root, EMPTY_MODULE_PATH)).toString()
239
+ const kind = isEmpty ? 'empty' : Bundle.isResourceFormat(format) ? 'resource' : 'code'
156
240
  return { path: fileURLToPath(url), namespace: 'file', pluginData: { isEntry, kind } }
157
241
  })
158
242
 
@@ -161,13 +245,21 @@ export class StasisEsbuild {
161
245
 
162
246
  // Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
163
247
  const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
164
- if (res.errors.length > 0) return res
165
- // Warnings from sibling plugins or esbuild itself (deprecation notices, etc.) are
166
- // not our concern -- propagate them upward, don't crash.
167
- // A non-empty `suffix` is set by sibling plugins that want to preserve a
168
- // query-style suffix on the resolved path (some CSS-modules plugins do).
169
- // stasis doesn't know how to persist suffixes to the bundle, so we refuse
170
- // them explicitly rather than silently dropping the suffix at capture time.
248
+ // A miss is esbuild's to report -- or to tolerate, as it does a require() in a try/catch (debug's
249
+ // optional supports-color). Decline so it re-resolves exactly as without us; no edge.
250
+ if (res.errors.length > 0) return undefined
251
+ // A disabled import (`browser` field `false`): returning `res` would bundle the real file or fail
252
+ // on a bare name, so decline and let esbuild re-resolve it into its own empty module. Capture
253
+ // points the edge at the attested empty module, which is what bundle=load serves.
254
+ if (!isEntry && await this.#isDisabled(res, specifier, args, { esbuild, initialOptions })) {
255
+ if (!this.#state.config.loadBundle) {
256
+ this.#emptyModule ??= this.#state.addEmptyModule({ reason: 'esbuild' })
257
+ this.#state.addImport(pathToFileURL(args.importer).toString(), specifier, this.#emptyModule, { importAttributes: attrs })
258
+ }
259
+ return undefined
260
+ }
261
+ // A non-empty `suffix` (some CSS-modules plugins set one) can't be persisted to the bundle,
262
+ // so refuse it rather than silently dropping it at capture.
171
263
  assert.equal(res.suffix, '',
172
264
  `StasisEsbuild: a sibling plugin set res.suffix='${res.suffix}' on '${specifier}'; ` +
173
265
  `stasis can't persist suffixes to the bundle. Disable that plugin or drop suffix from its resolve result.`)
@@ -182,13 +274,14 @@ export class StasisEsbuild {
182
274
  }],
183
275
  }
184
276
  }
185
- if (kind === 'code' && !isEntry) {
277
+ // Record the edge for BOTH code and resource targets (never entries): load mode resolves
278
+ // every specifier through this map, so a resource with no edge would miss at bundle=load and
279
+ // fall through to esbuild's disk resolver -- serving the asset UNATTESTED. kind is code|resource here.
280
+ if (!isEntry) {
186
281
  const parentURL = pathToFileURL(args.importer).toString()
187
282
  const url = pathToFileURL(res.path).toString()
188
- // Forward import attributes (e.g. `with { type: 'json' }`) symmetrically
189
- // with the load path: addImport stores under a conditions-with-attributes
190
- // key when attributes are present, so getImport at load time can find
191
- // the same edge.
283
+ // Forward import attributes symmetrically with load: addImport keys by attributes so
284
+ // getImport finds the same edge.
192
285
  this.#state.addImport(parentURL, specifier, url, { importAttributes: attrs })
193
286
  }
194
287
  }
@@ -199,52 +292,49 @@ export class StasisEsbuild {
199
292
  onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
200
293
  const kind = pluginData?.kind ?? 'skip'
201
294
  if (kind === 'skip') return undefined
202
- // Accept the `type` import attribute (e.g. `with { type: 'json' }`); reject
203
- // anything else loudly since saving import-attribute state to the lockfile
204
- // isn't designed yet.
295
+ // Accept only the `type` import attribute; other attributes aren't persistable to the lockfile yet.
205
296
  for (const k of Object.keys(attrs)) {
206
297
  assert.equal(k, 'type', `unsupported import attribute: ${k}`)
207
298
  }
208
299
 
209
- // Load mode: serve bytes the bundle attested. `getFile` verifies the hash
210
- // against the lockfile (when one is loaded), and throws on a file the
211
- // bundle doesn't carry -- a missing in-scope file is a hard error, not a
212
- // silent disk fallback. Resources return `{ contents }` only; esbuild's
213
- // loader config (file/dataurl/copy/...) interprets and emits them by
214
- // extension, the same way capture mode lets esbuild's loader machinery
215
- // handle resource emission.
300
+ // Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
301
+ // the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
216
302
  if (this.#state.config.loadBundle) {
217
303
  const { source } = this.#state.getFile(pathToFileURL(path).toString())
218
- if (kind === 'resource') return { contents: source }
219
- return { contents: source, loader: this.#loaderFor(path) }
304
+ // A disabled import's stand-in gets esbuild's own loader for disabled modules: `empty` is the
305
+ // one a CSS @import, `composes` or url() accepts, so one empty module serves JS and CSS importers.
306
+ if (kind === 'empty') return { contents: source, loader: 'empty' }
307
+ if (kind === 'resource') {
308
+ // Plugin-provided contents with no `loader` default to `js` (esbuild applies the build's
309
+ // per-extension loader only on its own load path), so replay the configured loader from
310
+ // initialOptions.loader. No configured loader fails symmetrically with a capture build.
311
+ const loader = initialOptions.loader?.[extname(path)]
312
+ return loader ? { contents: source, loader } : { contents: source }
313
+ }
314
+ const loader = this.#loaderFor(path)
315
+ // A transform that declines (undefined) serves the attested bytes unchanged.
316
+ const transformed = await this.#transform?.(source, { path, loader })
317
+ return transformed ?? { contents: source, loader }
220
318
  }
221
319
 
222
320
  const source = await realReadFile(path)
223
321
 
224
322
  if (kind === 'resource') {
225
- // Mark this file as a resource. State picks the encoding from its bytes:
226
- // 'resource' (raw UTF-8 -- e.g. SVG stays human-readable in the bundle)
227
- // for valid UTF-8 content, 'resource:base64' for binary. We do NOT return
228
- // contents/loader: emission is esbuild's job via the build's own `loader`
229
- // config (file, dataurl, copy, ...), the same way webpack's file-loader
230
- // runs independently of stasis's afterResolve hook. An asset with no
231
- // configured loader errors in esbuild, which
232
- // is the user's responsibility (symmetric with file-loader being required).
323
+ // Mark as a resource (State picks 'resource' vs 'resource:base64' from bytes). Return no
324
+ // contents/loader: emission is esbuild's job via the build's own `loader` config.
233
325
  if (!this.#seen.has(path)) {
234
326
  this.#seen.add(path)
235
- this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'StasisEsbuild' })
327
+ this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'esbuild' })
236
328
  }
237
329
  return undefined
238
330
  }
239
331
 
240
- // Code-classified files must be UTF-8. A code extension with non-UTF-8 bytes
241
- // is malformed input (e.g. a .json mis-saved as UTF-16); refuse it rather
242
- // than silently encoding it as a resource and hiding the underlying problem.
332
+ // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
243
333
  assert.ok(isUtf8(source), `StasisEsbuild: code-classified file has non-UTF-8 bytes: ${path}`)
244
334
 
245
335
  if (!this.#seen.has(path)) {
246
336
  this.#seen.add(path)
247
- this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: 'StasisEsbuild' })
337
+ this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: 'esbuild' })
248
338
  }
249
339
 
250
340
  return { contents: source, loader: this.#loaderFor(path) }