@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/stasis-plugins",
3
- "version": "1.0.0-beta.9",
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.9"
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 { resolvePluginState } from './plugins.js'
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 so it
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
- switch (kind) {
23
- case 'require-call':
24
- case 'require-resolve':
25
- return { contents: `require(${quoted})`, loader: 'js' }
26
- case 'dynamic-import':
27
- return { contents: `import(${quoted})`, loader: 'js' }
28
- case 'import-rule':
29
- case 'composes-from':
30
- return { contents: `@import ${quoted};`, loader: 'css' }
31
- case 'url-token':
32
- return { contents: `a { b: url(${quoted}) }`, loader: 'css' }
33
- default:
34
- return { contents: `import ${quoted}`, loader: 'js' }
35
- }
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 to test against one, loosened past esbuild's matching (it tries a
39
- // path bare, plus each resolve extension, plus `/index`, with or without './'): no './' or `.`
40
- // segments, every extension stripped, trailing index segments dropped. Equal loose keys are a
41
- // SUPERSET of esbuild's matches -- only a probe decides.
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() // no (readable) package.json: no map
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
- #emptyModule // URL of the empty module disabled imports point at, once capture recorded it
75
- #browserScopes = new Map() // dir -> Promise<[{ dir, keys }]>: `false` keys of it and its ancestors
76
- #probes = new Map() // [kind, resolveDir, specifier] -> Promise<disabled path | null>
77
-
78
- // Build starts observed by this instance (across rebuilds and separate build()/context() calls);
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
- constructor(options = {}, { loaders, transform } = {}) {
83
- // A caller that owns a State (e.g. `stasis build`) can pass it directly; a foreign-copy State
84
- // fails closed via the instanceof miss.
85
- const state = options instanceof State
86
- ? options
87
- : resolvePluginState('StasisEsbuild', options, process.cwd()).state
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
- // esbuild loader for a served file: configured override, else derived from extension
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 package.json `browser` map disables something, nearest first.
112
- // esbuild consults only the nearest `browser` map, so this is a superset.
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
- let scopes = this.#browserScopes.get(dir)
115
- if (scopes === undefined) {
94
+ return upsert(this.#browserScopes, dir, () => {
116
95
  const parent = dirname(dir)
117
- scopes = Promise.all([browserFalseKeys(dir), parent === dir ? [] : this.#browserFalseScopes(parent)])
96
+ return Promise.all([browserFalseKeys(dir), parent === dir ? [] : this.#browserFalseScopes(parent)])
118
97
  .then(([keys, above]) => (keys.size > 0 ? [{ dir, keys }, ...above] : above))
119
- this.#browserScopes.set(dir, scopes)
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
- // a relative specifier, the path it names) under each map's dir, or a bare specifier as is or as
126
- // './<importer dir>/<specifier>' (a Browserify quirk esbuild keeps).
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 from the importer's dir with the build's resolve options
140
- // and every load stubbed empty, whose metafile still names a disabled module `(disabled):<path>`.
141
- // -> that path (absolute), or null when esbuild doesn't disable the import.
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
- const key = JSON.stringify([kind, resolveDir, specifier])
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
- probe = esbuild.build({
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
- this.#probes.set(key, probe)
162
- }
163
- return probe
135
+ })
164
136
  }
165
137
 
166
- // A `browser` field mapping this import to `false` makes esbuild resolve it to an empty "disabled"
167
- // module, but its resolve API drops that flag: a bare name comes back as a namespace-less path, a
168
- // file as its plain path. The file case is confirmed by a probe, and only where a `false` entry
169
- // could match (browser platform, see #mayBeDisabled).
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 // only a disabled bare name resolves to no namespace
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
- // Compare the paths: a sibling plugin may have resolved the import to something else entirely.
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 // noop plugin
151
+ if (!this.#state) return
152
+ const emptyModuleURL = pathToFileURL(resolvePath(this.#state.root, EMPTY_MODULE_PATH)).toString()
181
153
 
182
- // Watch/rebuild capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
183
- // changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Rebuilds re-fire
184
- // onStart against the same registrations, so an instance counter catches the second and errors.
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
- if (this.#captureBuildCount === 1) return undefined
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 (hooks.js); standalone/sidecar States are written on build finish --
198
- // only on a clean build (a partial one would overwrite the user's good file).
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 > 0) return
202
- this.#state.write()
165
+ if (result.errors.length === 0) this.#state.write()
203
166
  })
204
167
  }
205
168
 
206
- // Load mode: resolution comes from the bundle's import map, not esbuild's resolver -- the file
207
- // may not be on disk. Keep namespace:'file' with the original absolute path (esbuild doesn't stat
208
- // plugin-returned paths) so output bytes -- banners, asset names, source maps -- match a capture build.
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
- // Bare/relative entries are anchored to cwd (like the run loader), so the file needn't be on disk.
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
- // Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
222
- // plugin-load, both keyed under `* (with: ...)`. KNOWN GAP: a Node-loader-captured bundle
223
- // keys attributed edges under specific conditions the plugin's `*` lookup won't match.
224
- // The edge is looked up even for a built-in's name: a browser build records `fs` when a
225
- // `browser` field disables it (an edge to the empty module) or a package by that name serves it.
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 isEmpty = url === pathToFileURL(resolvePath(this.#state.root, EMPTY_MODULE_PATH)).toString()
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 -- or to tolerate, as it does a require() in a try/catch (debug's
249
- // optional supports-color). Decline so it re-resolves exactly as without us; no edge.
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
- // A disabled import (`browser` field `false`): returning `res` would bundle the real file or fail
252
- // on a bare name, so decline and let esbuild re-resolve it into its own empty module. Capture
253
- // points the edge at the attested empty module, which is what bundle=load serves.
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
- return {
272
- errors: [{
273
- text: `StasisEsbuild: unsupported extension for '${res.path}' -- add its extension or filename to the plugin's resources option or stop importing it`,
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
- // Accept only the `type` import attribute; other attributes aren't persistable to the lockfile yet.
296
- for (const k of Object.keys(attrs)) {
297
- assert.equal(k, 'type', `unsupported import attribute: ${k}`)
298
- }
226
+ assertPersistableAttributes(attrs)
299
227
 
300
- // Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
301
- // the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
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
- // A disabled import's stand-in gets esbuild's own loader for disabled modules: `empty` is the
305
- // one a CSS @import, `composes` or url() accepts, so one empty module serves JS and CSS importers.
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
- // Plugin-provided contents with no `loader` default to `js` (esbuild applies the build's
309
- // per-extension loader only on its own load path), so replay the configured loader from
310
- // initialOptions.loader. No configured loader fails symmetrically with a capture build.
311
- const loader = initialOptions.loader?.[extname(path)]
312
- return loader ? { contents: source, loader } : { contents: source }
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
- // A transform that declines (undefined) serves the attested bytes unchanged.
316
- const transformed = await this.#transform?.(source, { path, loader })
317
- return transformed ?? { contents: source, loader }
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 (kind === 'resource') {
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
+ }