@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.
- package/package.json +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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
107
|
-
// edge), so hand it back to esbuild
|
|
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
|
|
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
|
-
|
|
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
|