@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 +1 -1
- package/package.json +8 -6
- package/src/esbuild.js +208 -118
- package/src/metro-transformer.js +32 -71
- package/src/metro.js +343 -175
- package/src/plugins.js +51 -86
- package/src/rollup.js +327 -0
- package/src/webpack.js +84 -290
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.
|
|
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/
|
|
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/
|
|
32
|
+
"url": "https://github.com/PreventiveMeasures/stasis/issues"
|
|
31
33
|
},
|
|
32
|
-
"homepage": "https://github.com/
|
|
34
|
+
"homepage": "https://github.com/PreventiveMeasures/stasis#readme",
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"@exodus/stasis-core": "1.0.0-beta.
|
|
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 {
|
|
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,
|
|
11
|
-
//
|
|
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
|
|
25
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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' }
|
|
39
|
-
//
|
|
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
|
-
//
|
|
46
|
-
//
|
|
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
|
-
|
|
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
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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
|
|
85
|
-
//
|
|
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
|
-
//
|
|
97
|
-
//
|
|
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
|
-
//
|
|
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 (
|
|
127
|
-
// plugin-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
|
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
|
-
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
// A
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
189
|
-
//
|
|
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
|
|
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.
|
|
210
|
-
//
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
226
|
-
//
|
|
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: '
|
|
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
|
|
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: '
|
|
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) }
|