@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 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.0",
4
- "description": "Bundler plugins (webpack, esbuild, Metro) for @exodus/stasis-core",
3
+ "version": "1.0.0-beta.10",
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.0"
36
+ "@exodus/stasis-core": "1.0.0-beta.10"
35
37
  }
36
38
  }
package/src/esbuild.js CHANGED
@@ -1,45 +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>
77
+
78
+ // Build starts observed by this instance (across rebuilds and separate build()/context() calls);
79
+ // the second is refused in onStart.
80
+ #captureBuildCount = 0
23
81
 
24
- constructor(options = {}, { loaders } = {}) {
25
- // 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.
26
85
  const state = options instanceof State
27
86
  ? options
28
87
  : resolvePluginState('StasisEsbuild', options, process.cwd()).state
29
88
  this.#state = state // null when plugin should be inert
30
- // resources is a Config field now (validated + coordinated against any preload in
31
- // 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.
32
90
  this.#resources = state?.config.resources ?? new Set()
33
- // Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } so a .js file
34
- // carrying JSX -- the React Native convention -- parses; esbuild's default 'js'
35
- // loader rejects JSX). Keyed by extname WITH the dot; falls back to the
36
- // 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.
37
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
38
98
  }
39
99
 
40
- // The esbuild loader for a served file: an explicit per-extension override if the
41
- // caller configured one, else derived from the extension (.cjs/.mjs/.cts/.mts strip
42
- // 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).
43
102
  #loaderFor(path) {
44
103
  const ext = extname(path)
45
104
  return this.#loaders.get(ext) ?? ext.replace(/^\.[cm]?/u, '')
@@ -49,13 +108,94 @@ export class StasisEsbuild {
49
108
  return 'stasis'
50
109
  }
51
110
 
52
- setup = ({ onResolve, onLoad, 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 }) => {
53
180
  if (!this.#state) return // noop plugin
54
181
 
55
- // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
56
- // Standalone and sidecar States have no such hook, so the plugin writes them when the
57
- // build finishes -- but only on a clean build, otherwise a partial bundle/lockfile
58
- // would overwrite the user's good file.
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.
185
+ if (!this.#state.config.loadBundle) {
186
+ onStart(() => {
187
+ this.#captureBuildCount += 1
188
+ if (this.#captureBuildCount === 1) return undefined
189
+ return {
190
+ errors: [{
191
+ text: 'StasisEsbuild: watch/rebuild is not supported for capture -- run a one-shot build (rebuilds would silently attest stale content)',
192
+ }],
193
+ }
194
+ })
195
+ }
196
+
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).
59
199
  if (this.#state !== State.preload) {
60
200
  onEnd((result) => {
61
201
  if (result.errors.length > 0) return
@@ -63,67 +203,40 @@ export class StasisEsbuild {
63
203
  })
64
204
  }
65
205
 
66
- // Load mode: resolution comes from the bundle's import map, not esbuild's
67
- // resolver. The on-disk file may not even exist -- the bundle is the source
68
- // of truth (mirrors `stasis run --bundle=load`; see stasis-core/src/hooks.js).
69
- // We keep `namespace: 'file'` with the original absolute path, even though the
70
- // file isn't on disk: esbuild stats the path only when its own default resolver
71
- // produced it; paths returned from a plugin's onResolve are trusted as-is. That
72
- // gives the same output bytes as a capture-mode build -- esbuild's path-banner
73
- // 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.
74
209
  onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
75
210
  if (!this.#state.config.loadBundle) return undefined
76
- // Node built-ins (`constants`, `node:fs`, ...) are never carried in the bundle
77
- // and never recorded as import edges -- the capture side (and the Node run
78
- // loader, stasis-core/hooks.js) defer isBuiltin() specifiers before addImport
79
- // sees them. Returning undefined hands the builtin back to esbuild's own
80
- // resolution, which externalizes it under platform:'node' exactly as in a build
81
- // without this plugin; state.getImport would instead throw ERR_MODULE_NOT_FOUND
82
- // ("Cannot find module 'constants' imported from <file>"). Mirrors the webpack
83
- // plugin's load-mode builtin guard.
84
- if (isBuiltin(specifier)) return undefined
85
211
  const isEntry = resolveKind === 'entry-point'
86
212
  let url
87
213
  if (isEntry) {
88
- // esbuild's entryPoints are typically absolute, but a bare/relative entry
89
- // is anchored to cwd just like the run loader does (resolvePath(cwd, ...))
90
- // 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.
91
215
  url = specifier.startsWith('file:')
92
216
  ? specifier
93
217
  : pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
94
218
  if (this.#state.config.full) this.#state.assertEntry(url)
95
219
  } else {
96
220
  const parentURL = pathToFileURL(importer).toString()
97
- // Forward import attributes (e.g. `with { type: 'json' }`) so a
98
- // plugin-captured bundle round-trips to plugin-load symmetrically: both
99
- // sides key the edge under `* (with: ...)` (no conditions because the
100
- // plugin doesn't know them). KNOWN GAP: a bundle captured by the Node
101
- // run loader keys edges under specific conditions like `node, import,
102
- // ... (with: ...)`; the plugin's `*`-keyed lookup misses that, and the
103
- // wildcard fallback in state.getImport only falls back to `*` (without
104
- // attrs), so a Node-loader-captured bundle with attributed imports
105
- // won't reload via the esbuild plugin without further work
106
- // (state.getImport would need a multi-conditions match scan, or the
107
- // 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.
108
226
  try {
109
227
  ;({ url } = this.#state.getImport(parentURL, specifier, { importAttributes: attrs }))
110
228
  } catch (err) {
111
- // No attested edge. getImport tried the recorded edge under every conditions
112
- // bucket (resolveBundled), so a miss means the request names nothing the
113
- // bundle carries -- at a successful capture it was an EXTERNAL (a user
114
- // `external` entry; built-ins are handled above). Externals are never recorded
115
- // as edges, so return undefined to hand it back to esbuild, which externalizes it as it would
116
- // without this plugin. This relaxes only the resolve-time edge lookup:
117
- // a genuinely missing in-scope FILE still fails closed at onLoad
118
- // (state.getFile serves in-scope bytes from the bundle or throws), so no
119
- // disk fallback is introduced. A non-MODULE_NOT_FOUND error is a real
120
- // 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.
121
233
  if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
122
234
  throw err
123
235
  }
124
236
  }
125
237
  const format = this.#state.getFormat(url)
126
- 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'
127
240
  return { path: fileURLToPath(url), namespace: 'file', pluginData: { isEntry, kind } }
128
241
  })
129
242
 
@@ -132,13 +245,21 @@ export class StasisEsbuild {
132
245
 
133
246
  // Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
134
247
  const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
135
- if (res.errors.length > 0) return res
136
- // Warnings from sibling plugins or esbuild itself (deprecation notices, etc.) are
137
- // not our concern -- propagate them upward, don't crash.
138
- // A non-empty `suffix` is set by sibling plugins that want to preserve a
139
- // query-style suffix on the resolved path (some CSS-modules plugins do).
140
- // stasis doesn't know how to persist suffixes to the bundle, so we refuse
141
- // 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.
142
263
  assert.equal(res.suffix, '',
143
264
  `StasisEsbuild: a sibling plugin set res.suffix='${res.suffix}' on '${specifier}'; ` +
144
265
  `stasis can't persist suffixes to the bundle. Disable that plugin or drop suffix from its resolve result.`)
@@ -153,13 +274,14 @@ export class StasisEsbuild {
153
274
  }],
154
275
  }
155
276
  }
156
- 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) {
157
281
  const parentURL = pathToFileURL(args.importer).toString()
158
282
  const url = pathToFileURL(res.path).toString()
159
- // Forward import attributes (e.g. `with { type: 'json' }`) symmetrically
160
- // with the load path: addImport stores under a conditions-with-attributes
161
- // key when attributes are present, so getImport at load time can find
162
- // the same edge.
283
+ // Forward import attributes symmetrically with load: addImport keys by attributes so
284
+ // getImport finds the same edge.
163
285
  this.#state.addImport(parentURL, specifier, url, { importAttributes: attrs })
164
286
  }
165
287
  }
@@ -170,52 +292,49 @@ export class StasisEsbuild {
170
292
  onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
171
293
  const kind = pluginData?.kind ?? 'skip'
172
294
  if (kind === 'skip') return undefined
173
- // Accept the `type` import attribute (e.g. `with { type: 'json' }`); reject
174
- // anything else loudly since saving import-attribute state to the lockfile
175
- // isn't designed yet.
295
+ // Accept only the `type` import attribute; other attributes aren't persistable to the lockfile yet.
176
296
  for (const k of Object.keys(attrs)) {
177
297
  assert.equal(k, 'type', `unsupported import attribute: ${k}`)
178
298
  }
179
299
 
180
- // Load mode: serve bytes the bundle attested. `getFile` verifies the hash
181
- // against the lockfile (when one is loaded), and throws on a file the
182
- // bundle doesn't carry -- a missing in-scope file is a hard error, not a
183
- // silent disk fallback. Resources return `{ contents }` only; esbuild's
184
- // loader config (file/dataurl/copy/...) interprets and emits them by
185
- // extension, the same way capture mode lets esbuild's loader machinery
186
- // 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.
187
302
  if (this.#state.config.loadBundle) {
188
303
  const { source } = this.#state.getFile(pathToFileURL(path).toString())
189
- if (kind === 'resource') return { contents: source }
190
- 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 }
191
318
  }
192
319
 
193
320
  const source = await realReadFile(path)
194
321
 
195
322
  if (kind === 'resource') {
196
- // Mark this file as a resource. State picks the encoding from its bytes:
197
- // 'resource' (raw UTF-8 -- e.g. SVG stays human-readable in the bundle)
198
- // for valid UTF-8 content, 'resource:base64' for binary. We do NOT return
199
- // contents/loader: emission is esbuild's job via the build's own `loader`
200
- // config (file, dataurl, copy, ...), the same way webpack's file-loader
201
- // runs independently of stasis's afterResolve hook. An asset with no
202
- // configured loader errors in esbuild, which
203
- // 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.
204
325
  if (!this.#seen.has(path)) {
205
326
  this.#seen.add(path)
206
- 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' })
207
328
  }
208
329
  return undefined
209
330
  }
210
331
 
211
- // Code-classified files must be UTF-8. A code extension with non-UTF-8 bytes
212
- // is malformed input (e.g. a .json mis-saved as UTF-16); refuse it rather
213
- // 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.
214
333
  assert.ok(isUtf8(source), `StasisEsbuild: code-classified file has non-UTF-8 bytes: ${path}`)
215
334
 
216
335
  if (!this.#seen.has(path)) {
217
336
  this.#seen.add(path)
218
- 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' })
219
338
  }
220
339
 
221
340
  return { contents: source, loader: this.#loaderFor(path) }