@exodus/stasis-plugins 1.0.0-beta.9 → 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 +83 -170
- 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,44 +1,31 @@
|
|
|
1
|
-
import { isUtf8 } from 'node:buffer'
|
|
2
1
|
import { dirname, extname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
|
|
3
2
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
3
|
import assert from 'node:assert/strict'
|
|
5
4
|
|
|
6
5
|
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
7
|
-
import {
|
|
6
|
+
import { assertCodeUtf8, assertPersistableAttributes, attestedImport, ownOrResolvedState, rebuildRefused, unsupportedExtension } from './plugins.js'
|
|
8
7
|
import { State } from '@exodus/stasis-core/state'
|
|
9
8
|
// Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
|
|
10
9
|
// this plugin loads after, so a direct import would route esbuild's reads through the capture hook.
|
|
11
10
|
import { realReadFile } from '@exodus/stasis-core/state-util'
|
|
12
|
-
import { EMPTY_MODULE_PATH, classifyExtension } from '@exodus/stasis-core/util'
|
|
11
|
+
import { EMPTY_MODULE_PATH, classifyExtension, upsert } from '@exodus/stasis-core/util'
|
|
13
12
|
|
|
14
|
-
// The build options esbuild's resolver reads, replayed into the disabled-import probe
|
|
15
|
-
// resolves exactly as the build does.
|
|
13
|
+
// The build options esbuild's resolver reads, replayed into the disabled-import probe.
|
|
16
14
|
const RESOLVE_OPTIONS = ['absWorkingDir', 'alias', 'conditions', 'external', 'mainFields', 'nodePaths',
|
|
17
15
|
'packages', 'platform', 'preserveSymlinks', 'resolveExtensions', 'tsconfig', 'tsconfigRaw']
|
|
18
16
|
|
|
19
17
|
// One import of `specifier` as `kind` would issue it, as the probe's stdin.
|
|
20
18
|
function probeSource(specifier, kind) {
|
|
21
19
|
const quoted = JSON.stringify(specifier)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
}
|
|
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' }
|
|
36
25
|
}
|
|
37
26
|
|
|
38
|
-
// A `browser` map key or a path
|
|
39
|
-
//
|
|
40
|
-
// segments, every extension stripped, trailing index segments dropped. Equal loose keys are a
|
|
41
|
-
// SUPERSET of esbuild's matches -- only a probe decides.
|
|
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.
|
|
42
29
|
function looseBrowserKey(path) {
|
|
43
30
|
const parts = path.replaceAll('\\', '/').split('/').filter((part) => part !== '' && part !== '.')
|
|
44
31
|
while (parts.length > 0) {
|
|
@@ -58,7 +45,7 @@ async function browserFalseKeys(dir) {
|
|
|
58
45
|
try {
|
|
59
46
|
pkg = JSON.parse(await realReadFile(join(dir, 'package.json'), 'utf8'))
|
|
60
47
|
} catch {
|
|
61
|
-
return new Set()
|
|
48
|
+
return new Set()
|
|
62
49
|
}
|
|
63
50
|
const browser = pkg?.browser
|
|
64
51
|
if (browser === null || typeof browser !== 'object' || Array.isArray(browser)) return new Set()
|
|
@@ -71,34 +58,27 @@ export class StasisEsbuild {
|
|
|
71
58
|
#resources
|
|
72
59
|
#loaders
|
|
73
60
|
#transform
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// the second is refused in onStart.
|
|
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>
|
|
80
66
|
#captureBuildCount = 0
|
|
81
67
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
this.#state = state // null when plugin should be inert
|
|
89
|
-
// 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
|
|
90
74
|
this.#resources = state?.config.resources ?? new Set()
|
|
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.
|
|
93
75
|
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
76
|
this.#transform = transform
|
|
77
|
+
this.#resource = resource
|
|
78
|
+
this.#platform = platform
|
|
98
79
|
}
|
|
99
80
|
|
|
100
|
-
//
|
|
101
|
-
// (.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).
|
|
102
82
|
#loaderFor(path) {
|
|
103
83
|
const ext = extname(path)
|
|
104
84
|
return this.#loaders.get(ext) ?? ext.replace(/^\.[cm]?/u, '')
|
|
@@ -108,22 +88,19 @@ export class StasisEsbuild {
|
|
|
108
88
|
return 'stasis'
|
|
109
89
|
}
|
|
110
90
|
|
|
111
|
-
// Every directory from `dir` up whose
|
|
112
|
-
//
|
|
91
|
+
// Every directory from `dir` up whose `browser` map disables something, nearest first: a superset, as esbuild
|
|
92
|
+
// consults only the nearest map.
|
|
113
93
|
#browserFalseScopes(dir) {
|
|
114
|
-
|
|
115
|
-
if (scopes === undefined) {
|
|
94
|
+
return upsert(this.#browserScopes, dir, () => {
|
|
116
95
|
const parent = dirname(dir)
|
|
117
|
-
|
|
96
|
+
return Promise.all([browserFalseKeys(dir), parent === dir ? [] : this.#browserFalseScopes(parent)])
|
|
118
97
|
.then(([keys, above]) => (keys.size > 0 ? [{ dir, keys }, ...above] : above))
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
return scopes
|
|
98
|
+
})
|
|
122
99
|
}
|
|
123
100
|
|
|
124
|
-
// Whether some `browser` map `false` entry could match this resolution: the resolved path (and, for
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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).
|
|
127
104
|
async #mayBeDisabled(path, specifier, resolveDir) {
|
|
128
105
|
const isPackagePath = !isAbsolute(specifier) && !/^\.\.?(?:\/|$)/u.test(specifier) && !specifier.startsWith('/')
|
|
129
106
|
const paths = isPackagePath ? [path] : [path, resolvePath(resolveDir, specifier)]
|
|
@@ -136,16 +113,13 @@ export class StasisEsbuild {
|
|
|
136
113
|
})
|
|
137
114
|
}
|
|
138
115
|
|
|
139
|
-
// esbuild's own verdict: a one-import build
|
|
140
|
-
//
|
|
141
|
-
// -> that path (absolute), or null when esbuild doesn't disable the import.
|
|
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
|
|
142
118
|
#probeDisabled(specifier, kind, resolveDir, { esbuild, initialOptions }) {
|
|
143
|
-
|
|
144
|
-
let probe = this.#probes.get(key)
|
|
145
|
-
if (probe === undefined) {
|
|
119
|
+
return upsert(this.#probes, JSON.stringify([kind, resolveDir, specifier]), () => {
|
|
146
120
|
const options = Object.fromEntries(RESOLVE_OPTIONS.filter((k) => initialOptions[k] !== undefined).map((k) => [k, initialOptions[k]]))
|
|
147
121
|
const cwd = initialOptions.absWorkingDir ?? process.cwd()
|
|
148
|
-
|
|
122
|
+
return esbuild.build({
|
|
149
123
|
...options,
|
|
150
124
|
stdin: { ...probeSource(specifier, kind), resolveDir },
|
|
151
125
|
bundle: true,
|
|
@@ -158,99 +132,71 @@ export class StasisEsbuild {
|
|
|
158
132
|
if (!found?.startsWith('(disabled):')) return null
|
|
159
133
|
return resolvePath(cwd, found.slice('(disabled):'.length))
|
|
160
134
|
}, () => null)
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
return probe
|
|
135
|
+
})
|
|
164
136
|
}
|
|
165
137
|
|
|
166
|
-
// A `browser` field
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// could match (browser platform, see #mayBeDisabled).
|
|
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.
|
|
170
141
|
async #isDisabled(res, specifier, { kind, resolveDir }, build) {
|
|
171
142
|
if (res.external) return false
|
|
172
|
-
if (res.namespace === '') return true
|
|
143
|
+
if (res.namespace === '') return true
|
|
173
144
|
if (res.namespace !== 'file' || (build.initialOptions.platform ?? 'browser') !== 'browser') return false
|
|
174
145
|
if (!(await this.#mayBeDisabled(res.path, specifier, resolveDir))) return false
|
|
175
|
-
//
|
|
146
|
+
// A sibling plugin may have resolved the import to something else entirely.
|
|
176
147
|
return (await this.#probeDisabled(specifier, kind, resolveDir, build)) === res.path
|
|
177
148
|
}
|
|
178
149
|
|
|
179
150
|
setup = ({ onResolve, onLoad, onStart, onEnd, resolve, initialOptions, esbuild }) => {
|
|
180
|
-
if (!this.#state) return
|
|
151
|
+
if (!this.#state) return
|
|
152
|
+
const emptyModuleURL = pathToFileURL(resolvePath(this.#state.root, EMPTY_MODULE_PATH)).toString()
|
|
181
153
|
|
|
182
|
-
//
|
|
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.
|
|
154
|
+
// Rebuilds re-fire onStart against the same registrations.
|
|
185
155
|
if (!this.#state.config.loadBundle) {
|
|
186
156
|
onStart(() => {
|
|
187
157
|
this.#captureBuildCount += 1
|
|
188
|
-
|
|
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
|
-
}
|
|
158
|
+
return this.#captureBuildCount === 1 ? undefined : { errors: [{ text: rebuildRefused('StasisEsbuild') }] }
|
|
194
159
|
})
|
|
195
160
|
}
|
|
196
161
|
|
|
197
|
-
// The preload writes itself
|
|
198
|
-
// 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.
|
|
199
163
|
if (this.#state !== State.preload) {
|
|
200
164
|
onEnd((result) => {
|
|
201
|
-
if (result.errors.length
|
|
202
|
-
this.#state.write()
|
|
165
|
+
if (result.errors.length === 0) this.#state.write()
|
|
203
166
|
})
|
|
204
167
|
}
|
|
205
168
|
|
|
206
|
-
// Load mode
|
|
207
|
-
//
|
|
208
|
-
// 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.
|
|
209
171
|
onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
|
|
210
172
|
if (!this.#state.config.loadBundle) return undefined
|
|
211
173
|
const isEntry = resolveKind === 'entry-point'
|
|
212
174
|
let url
|
|
213
175
|
if (isEntry) {
|
|
214
|
-
//
|
|
215
|
-
url = specifier.startsWith('file:')
|
|
216
|
-
? specifier
|
|
217
|
-
: 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()
|
|
218
178
|
if (this.#state.config.full) this.#state.assertEntry(url)
|
|
219
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.
|
|
220
183
|
const parentURL = pathToFileURL(importer).toString()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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.
|
|
226
|
-
try {
|
|
227
|
-
;({ url } = this.#state.getImport(parentURL, specifier, { importAttributes: attrs }))
|
|
228
|
-
} catch (err) {
|
|
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.
|
|
233
|
-
if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
|
|
234
|
-
throw err
|
|
235
|
-
}
|
|
184
|
+
const attested = attestedImport(this.#state, parentURL, specifier, { importAttributes: attrs, platform: this.#platform })
|
|
185
|
+
if (attested === undefined) return undefined
|
|
186
|
+
;({ url } = attested)
|
|
236
187
|
}
|
|
237
188
|
const format = this.#state.getFormat(url)
|
|
238
|
-
const
|
|
239
|
-
const kind = isEmpty ? 'empty' : Bundle.isResourceFormat(format) ? 'resource' : 'code'
|
|
189
|
+
const kind = url === emptyModuleURL ? 'empty' : Bundle.isResourceFormat(format) ? 'resource' : 'code'
|
|
240
190
|
return { path: fileURLToPath(url), namespace: 'file', pluginData: { isEntry, kind } }
|
|
241
191
|
})
|
|
242
192
|
|
|
243
193
|
onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, with: attrs, ...args }) => {
|
|
244
194
|
const isEntry = !args.importer
|
|
245
|
-
|
|
246
|
-
// 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.
|
|
247
196
|
const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
|
|
248
|
-
// A miss is esbuild's to report
|
|
249
|
-
// optional supports-color). Decline so it re-resolves exactly as without us; no edge.
|
|
197
|
+
// A miss is esbuild's to report, or tolerate (a require() in a try/catch): decline, recording no edge.
|
|
250
198
|
if (res.errors.length > 0) return undefined
|
|
251
|
-
//
|
|
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.
|
|
199
|
+
// Declined, esbuild re-resolves a disabled import into its own empty module, the attested one bundle=load serves.
|
|
254
200
|
if (!isEntry && await this.#isDisabled(res, specifier, args, { esbuild, initialOptions })) {
|
|
255
201
|
if (!this.#state.config.loadBundle) {
|
|
256
202
|
this.#emptyModule ??= this.#state.addEmptyModule({ reason: 'esbuild' })
|
|
@@ -258,8 +204,6 @@ export class StasisEsbuild {
|
|
|
258
204
|
}
|
|
259
205
|
return undefined
|
|
260
206
|
}
|
|
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.
|
|
263
207
|
assert.equal(res.suffix, '',
|
|
264
208
|
`StasisEsbuild: a sibling plugin set res.suffix='${res.suffix}' on '${specifier}'; ` +
|
|
265
209
|
`stasis can't persist suffixes to the bundle. Disable that plugin or drop suffix from its resolve result.`)
|
|
@@ -267,77 +211,46 @@ export class StasisEsbuild {
|
|
|
267
211
|
let kind = 'skip'
|
|
268
212
|
if (res.namespace === 'file' && !res.external) {
|
|
269
213
|
kind = classifyExtension(res.path, this.#resources)
|
|
270
|
-
if (kind === 'unknown') {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
}
|
|
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) {
|
|
281
|
-
const parentURL = pathToFileURL(args.importer).toString()
|
|
282
|
-
const url = pathToFileURL(res.path).toString()
|
|
283
|
-
// Forward import attributes symmetrically with load: addImport keys by attributes so
|
|
284
|
-
// getImport finds the same edge.
|
|
285
|
-
this.#state.addImport(parentURL, specifier, url, { importAttributes: attrs })
|
|
286
|
-
}
|
|
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 })
|
|
287
219
|
}
|
|
288
|
-
|
|
289
220
|
return { ...res, pluginData: { ...res.pluginData, isEntry, kind } }
|
|
290
221
|
})
|
|
291
222
|
|
|
292
223
|
onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
|
|
293
224
|
const kind = pluginData?.kind ?? 'skip'
|
|
294
225
|
if (kind === 'skip') return undefined
|
|
295
|
-
|
|
296
|
-
for (const k of Object.keys(attrs)) {
|
|
297
|
-
assert.equal(k, 'type', `unsupported import attribute: ${k}`)
|
|
298
|
-
}
|
|
226
|
+
assertPersistableAttributes(attrs)
|
|
299
227
|
|
|
300
|
-
//
|
|
301
|
-
// 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.
|
|
302
229
|
if (this.#state.config.loadBundle) {
|
|
303
230
|
const { source } = this.#state.getFile(pathToFileURL(path).toString())
|
|
304
|
-
//
|
|
305
|
-
// one a CSS @import, `composes` or url() accepts, so one empty module serves JS and CSS importers.
|
|
231
|
+
// esbuild's own loader for disabled modules, which a CSS @import, `composes` or url() accepts too.
|
|
306
232
|
if (kind === 'empty') return { contents: source, loader: 'empty' }
|
|
307
233
|
if (kind === 'resource') {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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' }
|
|
313
240
|
}
|
|
314
241
|
const loader = this.#loaderFor(path)
|
|
315
|
-
|
|
316
|
-
const transformed = await this.#transform?.(source, { path, loader })
|
|
317
|
-
return transformed ?? { contents: source, loader }
|
|
242
|
+
return (await this.#transform?.(source, { path, loader })) ?? { contents: source, loader }
|
|
318
243
|
}
|
|
319
244
|
|
|
320
245
|
const source = await realReadFile(path)
|
|
321
|
-
|
|
322
|
-
if (
|
|
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.
|
|
325
|
-
if (!this.#seen.has(path)) {
|
|
326
|
-
this.#seen.add(path)
|
|
327
|
-
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'esbuild' })
|
|
328
|
-
}
|
|
329
|
-
return undefined
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
|
|
333
|
-
assert.ok(isUtf8(source), `StasisEsbuild: code-classified file has non-UTF-8 bytes: ${path}`)
|
|
334
|
-
|
|
246
|
+
const resource = kind === 'resource'
|
|
247
|
+
if (!resource) assertCodeUtf8('StasisEsbuild', path, source)
|
|
335
248
|
if (!this.#seen.has(path)) {
|
|
336
249
|
this.#seen.add(path)
|
|
337
|
-
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' })
|
|
338
251
|
}
|
|
339
|
-
|
|
340
|
-
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) }
|
|
341
254
|
})
|
|
342
255
|
}
|
|
343
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
|
+
}
|