@exodus/stasis-plugins 1.0.0-beta.8 → 1.0.0-rc.0
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 +6 -3
- package/src/esbuild.js +168 -115
- package/src/metro-babel-transformer.js +129 -0
- package/src/metro-native.js +201 -0
- package/src/metro-transformer.js +68 -55
- package/src/metro.js +258 -282
- package/src/plugins.js +102 -111
- package/src/rollup.js +64 -188
- package/src/webpack.js +61 -181
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis-plugins",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-rc.0",
|
|
4
4
|
"description": "Bundler plugins (webpack, esbuild, Rollup, Metro) for @exodus/stasis-core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
"./esbuild": "./src/esbuild.js",
|
|
9
9
|
"./rollup": "./src/rollup.js",
|
|
10
10
|
"./metro": "./src/metro.js",
|
|
11
|
-
"./metro-transformer": "./src/metro-transformer.js"
|
|
11
|
+
"./metro-transformer": "./src/metro-transformer.js",
|
|
12
|
+
"./metro-native": "./src/metro-native.js"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src/esbuild.js",
|
|
15
16
|
"src/metro.js",
|
|
17
|
+
"src/metro-babel-transformer.js",
|
|
18
|
+
"src/metro-native.js",
|
|
16
19
|
"src/metro-transformer.js",
|
|
17
20
|
"src/plugins.js",
|
|
18
21
|
"src/rollup.js",
|
|
@@ -33,6 +36,6 @@
|
|
|
33
36
|
},
|
|
34
37
|
"homepage": "https://github.com/PreventiveMeasures/stasis#readme",
|
|
35
38
|
"dependencies": {
|
|
36
|
-
"@exodus/stasis-core": "1.0.0-
|
|
39
|
+
"@exodus/stasis-core": "1.0.0-rc.0"
|
|
37
40
|
}
|
|
38
41
|
}
|
package/src/esbuild.js
CHANGED
|
@@ -1,16 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isBuiltin } from 'node:module'
|
|
3
|
-
import { extname, resolve as resolvePath } from 'node:path'
|
|
1
|
+
import { dirname, extname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
|
|
4
2
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
3
|
import assert from 'node:assert/strict'
|
|
6
4
|
|
|
7
5
|
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
8
|
-
import {
|
|
6
|
+
import { assertCodeUtf8, assertPersistableAttributes, attestedImport, ownOrResolvedState, rebuildRefused, unsupportedExtension } from './plugins.js'
|
|
9
7
|
import { State } from '@exodus/stasis-core/state'
|
|
10
8
|
// Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
|
|
11
9
|
// this plugin loads after, so a direct import would route esbuild's reads through the capture hook.
|
|
12
10
|
import { realReadFile } from '@exodus/stasis-core/state-util'
|
|
13
|
-
import { classifyExtension } from '@exodus/stasis-core/util'
|
|
11
|
+
import { EMPTY_MODULE_PATH, classifyExtension, upsert } from '@exodus/stasis-core/util'
|
|
12
|
+
|
|
13
|
+
// The build options esbuild's resolver reads, replayed into the disabled-import probe.
|
|
14
|
+
const RESOLVE_OPTIONS = ['absWorkingDir', 'alias', 'conditions', 'external', 'mainFields', 'nodePaths',
|
|
15
|
+
'packages', 'platform', 'preserveSymlinks', 'resolveExtensions', 'tsconfig', 'tsconfigRaw']
|
|
16
|
+
|
|
17
|
+
// One import of `specifier` as `kind` would issue it, as the probe's stdin.
|
|
18
|
+
function probeSource(specifier, kind) {
|
|
19
|
+
const quoted = JSON.stringify(specifier)
|
|
20
|
+
if (kind === 'require-call' || kind === 'require-resolve') return { contents: `require(${quoted})`, loader: 'js' }
|
|
21
|
+
if (kind === 'dynamic-import') return { contents: `import(${quoted})`, loader: 'js' }
|
|
22
|
+
if (kind === 'import-rule' || kind === 'composes-from') return { contents: `@import ${quoted};`, loader: 'css' }
|
|
23
|
+
if (kind === 'url-token') return { contents: `a { b: url(${quoted}) }`, loader: 'css' }
|
|
24
|
+
return { contents: `import ${quoted}`, loader: 'js' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A `browser` map key or a path, loosened past esbuild's matching (bare, plus each resolve extension, plus `/index`,
|
|
28
|
+
// with or without './'): equal loose keys are a SUPERSET of esbuild's matches -- only a probe decides.
|
|
29
|
+
function looseBrowserKey(path) {
|
|
30
|
+
const parts = path.replaceAll('\\', '/').split('/').filter((part) => part !== '' && part !== '.')
|
|
31
|
+
while (parts.length > 0) {
|
|
32
|
+
const stem = parts.at(-1).replace(/\..*$/su, '')
|
|
33
|
+
if (stem !== 'index' || parts.length === 1) {
|
|
34
|
+
parts[parts.length - 1] = stem
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
parts.pop()
|
|
38
|
+
}
|
|
39
|
+
return parts.join('/')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The loose keys a directory's package.json `browser` map sends to `false` (esbuild's "disabled").
|
|
43
|
+
async function browserFalseKeys(dir) {
|
|
44
|
+
let pkg
|
|
45
|
+
try {
|
|
46
|
+
pkg = JSON.parse(await realReadFile(join(dir, 'package.json'), 'utf8'))
|
|
47
|
+
} catch {
|
|
48
|
+
return new Set()
|
|
49
|
+
}
|
|
50
|
+
const browser = pkg?.browser
|
|
51
|
+
if (browser === null || typeof browser !== 'object' || Array.isArray(browser)) return new Set()
|
|
52
|
+
return new Set(Object.keys(browser).filter((key) => browser[key] === false).map((key) => looseBrowserKey(key)))
|
|
53
|
+
}
|
|
14
54
|
|
|
15
55
|
export class StasisEsbuild {
|
|
16
56
|
#seen = new Set()
|
|
@@ -18,31 +58,27 @@ export class StasisEsbuild {
|
|
|
18
58
|
#resources
|
|
19
59
|
#loaders
|
|
20
60
|
#transform
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// the
|
|
61
|
+
#resource
|
|
62
|
+
#platform
|
|
63
|
+
#emptyModule // URL of the empty module disabled imports point at, once capture recorded it
|
|
64
|
+
#browserScopes = new Map() // dir -> Promise<[{ dir, keys }]>: `false` keys of it and its ancestors
|
|
65
|
+
#probes = new Map() // [kind, resolveDir, specifier] -> Promise<disabled path | null>
|
|
24
66
|
#captureBuildCount = 0
|
|
25
67
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
this.#state = state // null when plugin should be inert
|
|
33
|
-
// Cache the resolved resources Set for the per-file classify hot path.
|
|
68
|
+
// Only for load mode: `loaders` per-extension overrides (RN's '.js' -> 'jsx'), `transform(source, { path, loader })`
|
|
69
|
+
// a code rewrite (`stasis build --platform=hermes`), `resource(source, { path })` a module for a resource no loader
|
|
70
|
+
// names (`stasis build --metro`'s asset module), `platform` the one a --metro per-platform edge resolves for.
|
|
71
|
+
constructor(options = {}, { loaders, transform, resource, platform } = {}) {
|
|
72
|
+
const state = ownOrResolvedState('StasisEsbuild', options)
|
|
73
|
+
this.#state = state
|
|
34
74
|
this.#resources = state?.config.resources ?? new Set()
|
|
35
|
-
// Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } for RN's JSX-in-.js),
|
|
36
|
-
// keyed by extname with the dot; falls back to #loaderFor.
|
|
37
75
|
this.#loaders = loaders ?? new Map()
|
|
38
|
-
// Load-mode-only code rewrite hook (e.g. `stasis build --platform=hermes` downleveling):
|
|
39
|
-
// (source, { path, loader }) => { contents, loader } | undefined, may be async. Never
|
|
40
|
-
// runs at capture -- capture attests the file's raw bytes.
|
|
41
76
|
this.#transform = transform
|
|
77
|
+
this.#resource = resource
|
|
78
|
+
this.#platform = platform
|
|
42
79
|
}
|
|
43
80
|
|
|
44
|
-
//
|
|
45
|
-
// (.cjs/.mjs/.cts/.mts strip their module letter to js/ts).
|
|
81
|
+
// A configured override, else from the extension (.cjs/.mjs/.cts/.mts drop their module letter).
|
|
46
82
|
#loaderFor(path) {
|
|
47
83
|
const ext = extname(path)
|
|
48
84
|
return this.#loaders.get(ext) ?? ext.replace(/^\.[cm]?/u, '')
|
|
@@ -52,77 +88,122 @@ export class StasisEsbuild {
|
|
|
52
88
|
return 'stasis'
|
|
53
89
|
}
|
|
54
90
|
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
// Every directory from `dir` up whose `browser` map disables something, nearest first: a superset, as esbuild
|
|
92
|
+
// consults only the nearest map.
|
|
93
|
+
#browserFalseScopes(dir) {
|
|
94
|
+
return upsert(this.#browserScopes, dir, () => {
|
|
95
|
+
const parent = dirname(dir)
|
|
96
|
+
return Promise.all([browserFalseKeys(dir), parent === dir ? [] : this.#browserFalseScopes(parent)])
|
|
97
|
+
.then(([keys, above]) => (keys.size > 0 ? [{ dir, keys }, ...above] : above))
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Whether some `browser` map `false` entry could match this resolution: the resolved path (and, for a relative
|
|
102
|
+
// specifier, the path it names) under each map's dir, or a bare specifier as is or as './<importer dir>/<specifier>'
|
|
103
|
+
// (a Browserify quirk esbuild keeps).
|
|
104
|
+
async #mayBeDisabled(path, specifier, resolveDir) {
|
|
105
|
+
const isPackagePath = !isAbsolute(specifier) && !/^\.\.?(?:\/|$)/u.test(specifier) && !specifier.startsWith('/')
|
|
106
|
+
const paths = isPackagePath ? [path] : [path, resolvePath(resolveDir, specifier)]
|
|
107
|
+
const dirs = new Set([resolveDir, ...paths.map((p) => dirname(p))])
|
|
108
|
+
const scopes = (await Promise.all([...dirs].map((dir) => this.#browserFalseScopes(dir)))).flat()
|
|
109
|
+
return scopes.some((scope) => {
|
|
110
|
+
const candidates = paths.map((p) => relative(scope.dir, p))
|
|
111
|
+
if (isPackagePath) candidates.push(specifier, join(relative(scope.dir, resolveDir), specifier))
|
|
112
|
+
return candidates.some((candidate) => scope.keys.has(looseBrowserKey(candidate)))
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// esbuild's own verdict: a one-import build with the build's resolve options and every load stubbed empty, whose
|
|
117
|
+
// metafile still names a disabled module `(disabled):<path>`. -> that path (absolute), or null
|
|
118
|
+
#probeDisabled(specifier, kind, resolveDir, { esbuild, initialOptions }) {
|
|
119
|
+
return upsert(this.#probes, JSON.stringify([kind, resolveDir, specifier]), () => {
|
|
120
|
+
const options = Object.fromEntries(RESOLVE_OPTIONS.filter((k) => initialOptions[k] !== undefined).map((k) => [k, initialOptions[k]]))
|
|
121
|
+
const cwd = initialOptions.absWorkingDir ?? process.cwd()
|
|
122
|
+
return esbuild.build({
|
|
123
|
+
...options,
|
|
124
|
+
stdin: { ...probeSource(specifier, kind), resolveDir },
|
|
125
|
+
bundle: true,
|
|
126
|
+
write: false,
|
|
127
|
+
metafile: true,
|
|
128
|
+
logLevel: 'silent',
|
|
129
|
+
plugins: [{ name: 'stasis-probe', setup: (build) => build.onLoad({ filter: /$/ }, () => ({ contents: '', loader: 'empty' })) }],
|
|
130
|
+
}).then(({ metafile }) => {
|
|
131
|
+
const found = metafile.inputs['<stdin>']?.imports.find((i) => i.original === specifier)?.path
|
|
132
|
+
if (!found?.startsWith('(disabled):')) return null
|
|
133
|
+
return resolvePath(cwd, found.slice('(disabled):'.length))
|
|
134
|
+
}, () => null)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
57
137
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
138
|
+
// A `browser` field `false` makes esbuild resolve an import to an empty "disabled" module, but its resolve API
|
|
139
|
+
// drops that flag: a bare name comes back namespace-less, a file as its plain path, which a probe confirms where a
|
|
140
|
+
// `false` entry could match.
|
|
141
|
+
async #isDisabled(res, specifier, { kind, resolveDir }, build) {
|
|
142
|
+
if (res.external) return false
|
|
143
|
+
if (res.namespace === '') return true
|
|
144
|
+
if (res.namespace !== 'file' || (build.initialOptions.platform ?? 'browser') !== 'browser') return false
|
|
145
|
+
if (!(await this.#mayBeDisabled(res.path, specifier, resolveDir))) return false
|
|
146
|
+
// A sibling plugin may have resolved the import to something else entirely.
|
|
147
|
+
return (await this.#probeDisabled(specifier, kind, resolveDir, build)) === res.path
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setup = ({ onResolve, onLoad, onStart, onEnd, resolve, initialOptions, esbuild }) => {
|
|
151
|
+
if (!this.#state) return
|
|
152
|
+
const emptyModuleURL = pathToFileURL(resolvePath(this.#state.root, EMPTY_MODULE_PATH)).toString()
|
|
153
|
+
|
|
154
|
+
// Rebuilds re-fire onStart against the same registrations.
|
|
61
155
|
if (!this.#state.config.loadBundle) {
|
|
62
156
|
onStart(() => {
|
|
63
157
|
this.#captureBuildCount += 1
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
errors: [{
|
|
67
|
-
text: 'StasisEsbuild: watch/rebuild is not supported for capture -- run a one-shot build (rebuilds would silently attest stale content)',
|
|
68
|
-
}],
|
|
69
|
-
}
|
|
158
|
+
return this.#captureBuildCount === 1 ? undefined : { errors: [{ text: rebuildRefused('StasisEsbuild') }] }
|
|
70
159
|
})
|
|
71
160
|
}
|
|
72
161
|
|
|
73
|
-
// The preload writes itself
|
|
74
|
-
// only on a clean build (a partial one would overwrite the user's good file).
|
|
162
|
+
// The preload writes itself; a failed build mustn't overwrite the user's good file.
|
|
75
163
|
if (this.#state !== State.preload) {
|
|
76
164
|
onEnd((result) => {
|
|
77
|
-
if (result.errors.length
|
|
78
|
-
this.#state.write()
|
|
165
|
+
if (result.errors.length === 0) this.#state.write()
|
|
79
166
|
})
|
|
80
167
|
}
|
|
81
168
|
|
|
82
|
-
// Load mode
|
|
83
|
-
//
|
|
84
|
-
// plugin-returned paths) so output bytes -- banners, asset names, source maps -- match a capture build.
|
|
169
|
+
// Load mode resolves through the bundle's import map: the file may not be on disk. namespace 'file' with the
|
|
170
|
+
// original path (esbuild doesn't stat a plugin's) keeps the output (banners, asset names, maps) a capture's.
|
|
85
171
|
onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
|
|
86
172
|
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
173
|
const isEntry = resolveKind === 'entry-point'
|
|
91
174
|
let url
|
|
92
175
|
if (isEntry) {
|
|
93
|
-
//
|
|
94
|
-
url = specifier.startsWith('file:')
|
|
95
|
-
? specifier
|
|
96
|
-
: pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
|
|
176
|
+
// Anchored to cwd, as the run loader does, so the file needn't be on disk.
|
|
177
|
+
url = specifier.startsWith('file:') ? specifier : pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
|
|
97
178
|
if (this.#state.config.full) this.#state.assertEntry(url)
|
|
98
179
|
} else {
|
|
180
|
+
// Looked up even for a built-in's name: a browser build records `fs` where a `browser` field disables it or a
|
|
181
|
+
// package by that name serves it. KNOWN GAP: a Node-loader-captured bundle keys an attributed edge under
|
|
182
|
+
// specific conditions, which this `*` lookup won't match.
|
|
99
183
|
const parentURL = pathToFileURL(importer).toString()
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
;({ url } = this.#state.getImport(parentURL, specifier, { importAttributes: attrs }))
|
|
105
|
-
} 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.
|
|
108
|
-
// A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
|
|
109
|
-
if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
|
|
110
|
-
throw err
|
|
111
|
-
}
|
|
184
|
+
const attested = attestedImport(this.#state, parentURL, specifier, { importAttributes: attrs, platform: this.#platform })
|
|
185
|
+
if (attested === undefined) return undefined
|
|
186
|
+
;({ url } = attested)
|
|
112
187
|
}
|
|
113
188
|
const format = this.#state.getFormat(url)
|
|
114
|
-
const kind = Bundle.isResourceFormat(format) ? 'resource' : 'code'
|
|
189
|
+
const kind = url === emptyModuleURL ? 'empty' : Bundle.isResourceFormat(format) ? 'resource' : 'code'
|
|
115
190
|
return { path: fileURLToPath(url), namespace: 'file', pluginData: { isEntry, kind } }
|
|
116
191
|
})
|
|
117
192
|
|
|
118
193
|
onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, with: attrs, ...args }) => {
|
|
119
194
|
const isEntry = !args.importer
|
|
120
|
-
|
|
121
|
-
// Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
|
|
195
|
+
// A synthetic namespace runs esbuild's own resolver without re-entering this hook.
|
|
122
196
|
const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
197
|
+
// A miss is esbuild's to report, or tolerate (a require() in a try/catch): decline, recording no edge.
|
|
198
|
+
if (res.errors.length > 0) return undefined
|
|
199
|
+
// Declined, esbuild re-resolves a disabled import into its own empty module, the attested one bundle=load serves.
|
|
200
|
+
if (!isEntry && await this.#isDisabled(res, specifier, args, { esbuild, initialOptions })) {
|
|
201
|
+
if (!this.#state.config.loadBundle) {
|
|
202
|
+
this.#emptyModule ??= this.#state.addEmptyModule({ reason: 'esbuild' })
|
|
203
|
+
this.#state.addImport(pathToFileURL(args.importer).toString(), specifier, this.#emptyModule, { importAttributes: attrs })
|
|
204
|
+
}
|
|
205
|
+
return undefined
|
|
206
|
+
}
|
|
126
207
|
assert.equal(res.suffix, '',
|
|
127
208
|
`StasisEsbuild: a sibling plugin set res.suffix='${res.suffix}' on '${specifier}'; ` +
|
|
128
209
|
`stasis can't persist suffixes to the bundle. Disable that plugin or drop suffix from its resolve result.`)
|
|
@@ -130,74 +211,46 @@ export class StasisEsbuild {
|
|
|
130
211
|
let kind = 'skip'
|
|
131
212
|
if (res.namespace === 'file' && !res.external) {
|
|
132
213
|
kind = classifyExtension(res.path, this.#resources)
|
|
133
|
-
if (kind === 'unknown') {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
// Record the edge for BOTH code and resource targets (never entries): load mode resolves
|
|
141
|
-
// every specifier through this map, so a resource with no edge would miss at bundle=load and
|
|
142
|
-
// fall through to esbuild's disk resolver -- serving the asset UNATTESTED. kind is code|resource here.
|
|
143
|
-
if (!isEntry) {
|
|
144
|
-
const parentURL = pathToFileURL(args.importer).toString()
|
|
145
|
-
const url = pathToFileURL(res.path).toString()
|
|
146
|
-
// Forward import attributes symmetrically with load: addImport keys by attributes so
|
|
147
|
-
// getImport finds the same edge.
|
|
148
|
-
this.#state.addImport(parentURL, specifier, url, { importAttributes: attrs })
|
|
149
|
-
}
|
|
214
|
+
if (kind === 'unknown') return { errors: [{ text: unsupportedExtension('StasisEsbuild', `'${res.path}'`) }] }
|
|
215
|
+
// Resources too: load mode resolves every specifier through this map, so one with no edge would fall through
|
|
216
|
+
// to esbuild's disk resolver, unattested.
|
|
217
|
+
const url = pathToFileURL(res.path).toString()
|
|
218
|
+
if (!isEntry) this.#state.addImport(pathToFileURL(args.importer).toString(), specifier, url, { importAttributes: attrs })
|
|
150
219
|
}
|
|
151
|
-
|
|
152
220
|
return { ...res, pluginData: { ...res.pluginData, isEntry, kind } }
|
|
153
221
|
})
|
|
154
222
|
|
|
155
223
|
onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
|
|
156
224
|
const kind = pluginData?.kind ?? 'skip'
|
|
157
225
|
if (kind === 'skip') return undefined
|
|
158
|
-
|
|
159
|
-
for (const k of Object.keys(attrs)) {
|
|
160
|
-
assert.equal(k, 'type', `unsupported import attribute: ${k}`)
|
|
161
|
-
}
|
|
226
|
+
assertPersistableAttributes(attrs)
|
|
162
227
|
|
|
163
|
-
//
|
|
164
|
-
// the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
|
|
228
|
+
// getFile verifies the hash and throws on a file the bundle doesn't carry: no disk fallback.
|
|
165
229
|
if (this.#state.config.loadBundle) {
|
|
166
230
|
const { source } = this.#state.getFile(pathToFileURL(path).toString())
|
|
231
|
+
// esbuild's own loader for disabled modules, which a CSS @import, `composes` or url() accepts too.
|
|
232
|
+
if (kind === 'empty') return { contents: source, loader: 'empty' }
|
|
167
233
|
if (kind === 'resource') {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
234
|
+
const override = this.#loaders.get(extname(path))
|
|
235
|
+
if (override) return { contents: source, loader: override }
|
|
236
|
+
const built = await this.#resource?.(source, { path })
|
|
237
|
+
if (built) return built
|
|
238
|
+
// The build's own loader for it, so a resource none loads fails as at capture, not parsed as js.
|
|
239
|
+
return { contents: source, loader: 'default' }
|
|
173
240
|
}
|
|
174
241
|
const loader = this.#loaderFor(path)
|
|
175
|
-
|
|
176
|
-
const transformed = await this.#transform?.(source, { path, loader })
|
|
177
|
-
return transformed ?? { contents: source, loader }
|
|
242
|
+
return (await this.#transform?.(source, { path, loader })) ?? { contents: source, loader }
|
|
178
243
|
}
|
|
179
244
|
|
|
180
245
|
const source = await realReadFile(path)
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
// Mark as a resource (State picks 'resource' vs 'resource:base64' from bytes). Return no
|
|
184
|
-
// contents/loader: emission is esbuild's job via the build's own `loader` config.
|
|
185
|
-
if (!this.#seen.has(path)) {
|
|
186
|
-
this.#seen.add(path)
|
|
187
|
-
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'esbuild' })
|
|
188
|
-
}
|
|
189
|
-
return undefined
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
|
|
193
|
-
assert.ok(isUtf8(source), `StasisEsbuild: code-classified file has non-UTF-8 bytes: ${path}`)
|
|
194
|
-
|
|
246
|
+
const resource = kind === 'resource'
|
|
247
|
+
if (!resource) assertCodeUtf8('StasisEsbuild', path, source)
|
|
195
248
|
if (!this.#seen.has(path)) {
|
|
196
249
|
this.#seen.add(path)
|
|
197
|
-
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: 'esbuild' })
|
|
250
|
+
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource, reason: 'esbuild' })
|
|
198
251
|
}
|
|
199
|
-
|
|
200
|
-
return { contents: source, loader: this.#loaderFor(path) }
|
|
252
|
+
// A resource is emitted by esbuild, through the build's own `loader` config.
|
|
253
|
+
return resource ? undefined : { contents: source, loader: this.#loaderFor(path) }
|
|
201
254
|
})
|
|
202
255
|
}
|
|
203
256
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
|
|
5
|
+
// Stands in for the project's Babel transformer under ./metro-transformer.js, to keep each import as WRITTEN: Metro
|
|
6
|
+
// names a dependency by the specifier Babel leaves, so a rewrite (babel-plugin-module-resolver's) would leave it only
|
|
7
|
+
// that. An added plugin records each specifier in its `pre` (Babel runs every `pre` before any visitor) and reads it
|
|
8
|
+
// again once Babel is done; one a plugin consumed, with nothing left naming its module, is kept apart.
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
|
|
12
|
+
// Per worker transform: `babelTransformerPath` the project's, `asWritten` each specifier Babel left -> the ones
|
|
13
|
+
// written, and `consumed` each one a plugin consumed, as Babel last left it.
|
|
14
|
+
export const transformContext = new AsyncLocalStorage()
|
|
15
|
+
|
|
16
|
+
// The nodes Metro's collectDependencies takes a specifier from.
|
|
17
|
+
const DECLARATIONS = new Set(['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'])
|
|
18
|
+
const REQUIRE_MEMBERS = new Set(['context', 'resolveWeak', 'unstable_importMaybeSync'])
|
|
19
|
+
|
|
20
|
+
function isDependency(node) {
|
|
21
|
+
if (node.type !== 'CallExpression') return DECLARATIONS.has(node.type)
|
|
22
|
+
const { callee } = node
|
|
23
|
+
if (callee.type === 'Import') return true
|
|
24
|
+
if (callee.type === 'Identifier') return callee.name === 'require' || callee.name === '__prefetchImport'
|
|
25
|
+
return callee.type === 'MemberExpression' && !callee.computed &&
|
|
26
|
+
callee.object.type === 'Identifier' && callee.object.name === 'require' &&
|
|
27
|
+
callee.property.type === 'Identifier' && REQUIRE_MEMBERS.has(callee.property.name)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A literal, a template with no substitutions, or a `+` of such, which collectDependencies evaluates as a literal.
|
|
31
|
+
function constantString(node) {
|
|
32
|
+
if (node?.type === 'StringLiteral') return node.value
|
|
33
|
+
if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked ?? undefined
|
|
34
|
+
if (node?.type !== 'BinaryExpression' || node.operator !== '+') return undefined
|
|
35
|
+
const left = constantString(node.left)
|
|
36
|
+
const right = left === undefined ? undefined : constantString(node.right)
|
|
37
|
+
return right === undefined ? undefined : left + right
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A plugin rewriting a specifier replaces the literal, never the node holding it.
|
|
41
|
+
const specifierOf = (node) => constantString(node.type === 'CallExpression' ? node.arguments[0] : node.source)
|
|
42
|
+
|
|
43
|
+
const TYPE_KINDS = new Set(['type', 'typeof'])
|
|
44
|
+
|
|
45
|
+
// A declaration of types alone, which Babel may erase: Flow's or TypeScript's `import type`, `export type ... from`,
|
|
46
|
+
// or one whose every specifier is a type (which TypeScript's `onlyRemoveTypeImports` keeps as `import {} from`).
|
|
47
|
+
function typesOnly(node) {
|
|
48
|
+
if (TYPE_KINDS.has(node.importKind ?? node.exportKind)) return true
|
|
49
|
+
const specifiers = node.specifiers ?? []
|
|
50
|
+
return specifiers.length > 0 && specifiers.every((specifier) => TYPE_KINDS.has(specifier.importKind ?? specifier.exportKind))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dependenciesLeft(t, ast) {
|
|
54
|
+
const nodes = new Set()
|
|
55
|
+
const specifiers = new Set()
|
|
56
|
+
t.traverseFast(ast, (node) => {
|
|
57
|
+
if (!isDependency(node)) return
|
|
58
|
+
nodes.add(node)
|
|
59
|
+
const specifier = specifierOf(node)
|
|
60
|
+
if (specifier !== undefined) specifiers.add(specifier)
|
|
61
|
+
})
|
|
62
|
+
return { nodes, specifiers }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asWrittenPlugin({ types: t }) {
|
|
66
|
+
return {
|
|
67
|
+
name: 'stasis-as-written',
|
|
68
|
+
pre(file) {
|
|
69
|
+
const context = transformContext.getStore()
|
|
70
|
+
context.types = t
|
|
71
|
+
const { slots } = context
|
|
72
|
+
let evaluate = false
|
|
73
|
+
t.traverseFast(file.ast, (node) => {
|
|
74
|
+
if (!isDependency(node)) return
|
|
75
|
+
const written = specifierOf(node)
|
|
76
|
+
if (written !== undefined) slots.push({ node, written, types: node.type !== 'CallExpression' && typesOnly(node) })
|
|
77
|
+
else if (node.type === 'CallExpression' && node.arguments.length > 0) evaluate = true
|
|
78
|
+
})
|
|
79
|
+
// Metro also takes an argument Babel evaluates to a string with confidence (`const name = './x'; require(name)`),
|
|
80
|
+
// which only a path, with its scope, can evaluate.
|
|
81
|
+
if (!evaluate) return
|
|
82
|
+
file.path.traverse({
|
|
83
|
+
CallExpression(path) {
|
|
84
|
+
if (!isDependency(path.node) || specifierOf(path.node) !== undefined || path.node.arguments.length === 0) return
|
|
85
|
+
const { confident, value } = path.get('arguments.0').evaluate()
|
|
86
|
+
if (confident && typeof value === 'string') slots.push({ node: path.node, written: value })
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
visitor: {
|
|
91
|
+
// Babel exits a node only if no plugin removed it on the way in, so a types-only declaration it erased never
|
|
92
|
+
// exits; one it kept (`import {} from`) does, still naming its module.
|
|
93
|
+
'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': {
|
|
94
|
+
exit(path) {
|
|
95
|
+
transformContext.getStore().kept.add(path.node)
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function transform(args) {
|
|
103
|
+
const context = transformContext.getStore()
|
|
104
|
+
assert.ok(context, 'StasisMetro: the stasis Babel transformer runs only under the stasis metro-transformer')
|
|
105
|
+
const slots = []
|
|
106
|
+
const kept = new Set()
|
|
107
|
+
Object.assign(context, { slots, kept })
|
|
108
|
+
// Each specifier as Babel left it in the node that held it as written, but an erased types-only declaration's. A
|
|
109
|
+
// node a plugin replaced (lowered to a require, or split in two) names the same module, its specifier as rewritten
|
|
110
|
+
// before; one gone from the output with no node there naming its module was consumed.
|
|
111
|
+
const collect = (result) => {
|
|
112
|
+
const left = result?.ast && context.types ? dependenciesLeft(context.types, result.ast) : null
|
|
113
|
+
for (const { node, written, types } of slots) {
|
|
114
|
+
if (types && !kept.has(node)) continue
|
|
115
|
+
// One evaluated through its scope stays as written: no plugin rewrites what isn't a literal.
|
|
116
|
+
const specifier = specifierOf(node) ?? written
|
|
117
|
+
if (left !== null && !left.nodes.has(node) && !left.specifiers.has(specifier)) {
|
|
118
|
+
context.consumed.add(specifier)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (!context.asWritten.has(specifier)) context.asWritten.set(specifier, new Set())
|
|
122
|
+
context.asWritten.get(specifier).add(written)
|
|
123
|
+
}
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
// Sync or async, as the project's transformer is.
|
|
127
|
+
const result = require(context.babelTransformerPath).transform({ ...args, plugins: [...(args.plugins ?? []), asWrittenPlugin] })
|
|
128
|
+
return typeof result?.then === 'function' ? result.then(collect) : collect(result)
|
|
129
|
+
}
|