@exodus/stasis-plugins 1.0.0-beta.8 → 1.0.0-beta.9

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/esbuild.js +151 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/stasis-plugins",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.0-beta.9",
4
4
  "description": "Bundler plugins (webpack, esbuild, Rollup, Metro) for @exodus/stasis-core",
5
5
  "type": "module",
6
6
  "exports": {
@@ -33,6 +33,6 @@
33
33
  },
34
34
  "homepage": "https://github.com/PreventiveMeasures/stasis#readme",
35
35
  "dependencies": {
36
- "@exodus/stasis-core": "1.0.0-beta.8"
36
+ "@exodus/stasis-core": "1.0.0-beta.9"
37
37
  }
38
38
  }
package/src/esbuild.js CHANGED
@@ -1,6 +1,5 @@
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
 
@@ -10,7 +9,61 @@ import { State } from '@exodus/stasis-core/state'
10
9
  // Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
11
10
  // this plugin loads after, so a direct import would route esbuild's reads through the capture hook.
12
11
  import { realReadFile } from '@exodus/stasis-core/state-util'
13
- 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
+ }
14
67
 
15
68
  export class StasisEsbuild {
16
69
  #seen = new Set()
@@ -18,6 +71,9 @@ export class StasisEsbuild {
18
71
  #resources
19
72
  #loaders
20
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>
21
77
 
22
78
  // Build starts observed by this instance (across rebuilds and separate build()/context() calls);
23
79
  // the second is refused in onStart.
@@ -52,7 +108,75 @@ export class StasisEsbuild {
52
108
  return 'stasis'
53
109
  }
54
110
 
55
- setup = ({ onResolve, onLoad, onStart, onEnd, resolve, initialOptions }) => {
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 }) => {
56
180
  if (!this.#state) return // noop plugin
57
181
 
58
182
  // Watch/rebuild capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
@@ -84,9 +208,6 @@ export class StasisEsbuild {
84
208
  // plugin-returned paths) so output bytes -- banners, asset names, source maps -- match a capture build.
85
209
  onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
86
210
  if (!this.#state.config.loadBundle) return undefined
87
- // Built-ins are never bundled or recorded as edges; hand them back to esbuild (undefined)
88
- // so it externalizes under platform:'node', rather than getImport throwing ERR_MODULE_NOT_FOUND.
89
- if (isBuiltin(specifier)) return undefined
90
211
  const isEntry = resolveKind === 'entry-point'
91
212
  let url
92
213
  if (isEntry) {
@@ -100,18 +221,22 @@ export class StasisEsbuild {
100
221
  // Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
101
222
  // plugin-load, both keyed under `* (with: ...)`. KNOWN GAP: a Node-loader-captured bundle
102
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.
103
226
  try {
104
227
  ;({ url } = this.#state.getImport(parentURL, specifier, { importAttributes: attrs }))
105
228
  } catch (err) {
106
- // No attested edge -- at a successful capture that's an EXTERNAL (never recorded as an
107
- // edge), so hand it back to esbuild. File BYTES still fail closed at onLoad.
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.
108
232
  // A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
109
233
  if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
110
234
  throw err
111
235
  }
112
236
  }
113
237
  const format = this.#state.getFormat(url)
114
- 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'
115
240
  return { path: fileURLToPath(url), namespace: 'file', pluginData: { isEntry, kind } }
116
241
  })
117
242
 
@@ -120,7 +245,19 @@ export class StasisEsbuild {
120
245
 
121
246
  // Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
122
247
  const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
123
- if (res.errors.length > 0) return res
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
+ }
124
261
  // A non-empty `suffix` (some CSS-modules plugins set one) can't be persisted to the bundle,
125
262
  // so refuse it rather than silently dropping it at capture.
126
263
  assert.equal(res.suffix, '',
@@ -164,6 +301,9 @@ export class StasisEsbuild {
164
301
  // the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
165
302
  if (this.#state.config.loadBundle) {
166
303
  const { source } = this.#state.getFile(pathToFileURL(path).toString())
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' }
167
307
  if (kind === 'resource') {
168
308
  // Plugin-provided contents with no `loader` default to `js` (esbuild applies the build's
169
309
  // per-extension loader only on its own load path), so replay the configured loader from