@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/stasis-plugins",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.0-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-beta.8"
39
+ "@exodus/stasis-core": "1.0.0-rc.0"
37
40
  }
38
41
  }
package/src/esbuild.js CHANGED
@@ -1,16 +1,56 @@
1
- import { isUtf8 } from 'node:buffer'
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 { resolvePluginState } from './plugins.js'
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
- // Build starts observed by this instance (across rebuilds and separate build()/context() calls);
23
- // 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>
24
66
  #captureBuildCount = 0
25
67
 
26
- constructor(options = {}, { loaders, transform } = {}) {
27
- // A caller that owns a State (e.g. `stasis build`) can pass it directly; a foreign-copy State
28
- // fails closed via the instanceof miss.
29
- const state = options instanceof State
30
- ? options
31
- : resolvePluginState('StasisEsbuild', options, process.cwd()).state
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
- // esbuild loader for a served file: configured override, else derived from extension
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
- setup = ({ onResolve, onLoad, onStart, onEnd, resolve, initialOptions }) => {
56
- if (!this.#state) return // noop plugin
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
- // Watch/rebuild capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
59
- // changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Rebuilds re-fire
60
- // onStart against the same registrations, so an instance counter catches the second and errors.
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
- if (this.#captureBuildCount === 1) return undefined
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 (hooks.js); standalone/sidecar States are written on build finish --
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 > 0) return
78
- this.#state.write()
165
+ if (result.errors.length === 0) this.#state.write()
79
166
  })
80
167
  }
81
168
 
82
- // Load mode: resolution comes from the bundle's import map, not esbuild's resolver -- the file
83
- // may not be on disk. Keep namespace:'file' with the original absolute path (esbuild doesn't stat
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
- // Bare/relative entries are anchored to cwd (like the run loader), so the file needn't be on disk.
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
- // Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
101
- // plugin-load, both keyed under `* (with: ...)`. KNOWN GAP: a Node-loader-captured bundle
102
- // keys attributed edges under specific conditions the plugin's `*` lookup won't match.
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
- if (res.errors.length > 0) return res
124
- // A non-empty `suffix` (some CSS-modules plugins set one) can't be persisted to the bundle,
125
- // so refuse it rather than silently dropping it at capture.
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
- return {
135
- errors: [{
136
- text: `StasisEsbuild: unsupported extension for '${res.path}' -- add its extension or filename to the plugin's resources option or stop importing it`,
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
- // Accept only the `type` import attribute; other attributes aren't persistable to the lockfile yet.
159
- for (const k of Object.keys(attrs)) {
160
- assert.equal(k, 'type', `unsupported import attribute: ${k}`)
161
- }
226
+ assertPersistableAttributes(attrs)
162
227
 
163
- // Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
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
- // Plugin-provided contents with no `loader` default to `js` (esbuild applies the build's
169
- // per-extension loader only on its own load path), so replay the configured loader from
170
- // initialOptions.loader. No configured loader fails symmetrically with a capture build.
171
- const loader = initialOptions.loader?.[extname(path)]
172
- return loader ? { contents: source, loader } : { contents: source }
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
- // A transform that declines (undefined) serves the attested bytes unchanged.
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 (kind === 'resource') {
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
+ }