@exodus/stasis-plugins 1.0.0-beta.15 → 1.0.0-beta.16

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.15",
3
+ "version": "1.0.0-beta.16",
4
4
  "description": "Bundler plugins (webpack, esbuild, Rollup, Metro) for @exodus/stasis-core",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,6 +36,6 @@
36
36
  },
37
37
  "homepage": "https://github.com/PreventiveMeasures/stasis#readme",
38
38
  "dependencies": {
39
- "@exodus/stasis-core": "1.0.0-beta.15"
39
+ "@exodus/stasis-core": "1.0.0-beta.16"
40
40
  }
41
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 { attestedImport, resolvePluginState, unsupportedExtension } 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()
@@ -73,40 +60,25 @@ export class StasisEsbuild {
73
60
  #transform
74
61
  #resource
75
62
  #platform
76
- #emptyModule // URL of the empty module disabled imports point at, once capture recorded it
77
- #browserScopes = new Map() // dir -> Promise<[{ dir, keys }]>: `false` keys of it and its ancestors
78
- #probes = new Map() // [kind, resolveDir, specifier] -> Promise<disabled path | null>
79
-
80
- // Build starts observed by this instance (across rebuilds and separate build()/context() calls);
81
- // the second is refused in onStart.
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>
82
66
  #captureBuildCount = 0
83
67
 
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.
84
71
  constructor(options = {}, { loaders, transform, resource, platform } = {}) {
85
- // A caller that owns a State (e.g. `stasis build`) can pass it directly; a foreign-copy State
86
- // fails closed via the instanceof miss.
87
- const state = options instanceof State
88
- ? options
89
- : resolvePluginState('StasisEsbuild', options, process.cwd()).state
90
- this.#state = state // null when plugin should be inert
91
- // Cache the resolved resources Set for the per-file classify hot path.
72
+ const state = ownOrResolvedState('StasisEsbuild', options)
73
+ this.#state = state
92
74
  this.#resources = state?.config.resources ?? new Set()
93
- // Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } for RN's JSX-in-.js),
94
- // keyed by extname with the dot; falls back to #loaderFor.
95
75
  this.#loaders = loaders ?? new Map()
96
- // Load-mode-only code rewrite hook (e.g. `stasis build --platform=hermes` downleveling):
97
- // (source, { path, loader }) => { contents, loader } | undefined, may be async. Never
98
- // runs at capture -- capture attests the file's raw bytes.
99
76
  this.#transform = transform
100
- // Load-mode-only module builder for a resource no loader override names (e.g. `stasis build
101
- // --metro`'s asset module): (source, { path }) => { contents, loader } | undefined, may be async.
102
77
  this.#resource = resource
103
- // Load mode: the platform whose target a --metro per-platform edge resolves to (none fails
104
- // closed on one, as plain bundle=load does).
105
78
  this.#platform = platform
106
79
  }
107
80
 
108
- // esbuild loader for a served file: configured override, else derived from extension
109
- // (.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).
110
82
  #loaderFor(path) {
111
83
  const ext = extname(path)
112
84
  return this.#loaders.get(ext) ?? ext.replace(/^\.[cm]?/u, '')
@@ -116,22 +88,19 @@ export class StasisEsbuild {
116
88
  return 'stasis'
117
89
  }
118
90
 
119
- // Every directory from `dir` up whose package.json `browser` map disables something, nearest first.
120
- // 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.
121
93
  #browserFalseScopes(dir) {
122
- let scopes = this.#browserScopes.get(dir)
123
- if (scopes === undefined) {
94
+ return upsert(this.#browserScopes, dir, () => {
124
95
  const parent = dirname(dir)
125
- scopes = Promise.all([browserFalseKeys(dir), parent === dir ? [] : this.#browserFalseScopes(parent)])
96
+ return Promise.all([browserFalseKeys(dir), parent === dir ? [] : this.#browserFalseScopes(parent)])
126
97
  .then(([keys, above]) => (keys.size > 0 ? [{ dir, keys }, ...above] : above))
127
- this.#browserScopes.set(dir, scopes)
128
- }
129
- return scopes
98
+ })
130
99
  }
131
100
 
132
- // Whether some `browser` map `false` entry could match this resolution: the resolved path (and, for
133
- // a relative specifier, the path it names) under each map's dir, or a bare specifier as is or as
134
- // './<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).
135
104
  async #mayBeDisabled(path, specifier, resolveDir) {
136
105
  const isPackagePath = !isAbsolute(specifier) && !/^\.\.?(?:\/|$)/u.test(specifier) && !specifier.startsWith('/')
137
106
  const paths = isPackagePath ? [path] : [path, resolvePath(resolveDir, specifier)]
@@ -144,16 +113,13 @@ export class StasisEsbuild {
144
113
  })
145
114
  }
146
115
 
147
- // esbuild's own verdict: a one-import build from the importer's dir with the build's resolve options
148
- // and every load stubbed empty, whose metafile still names a disabled module `(disabled):<path>`.
149
- // -> 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
150
118
  #probeDisabled(specifier, kind, resolveDir, { esbuild, initialOptions }) {
151
- const key = JSON.stringify([kind, resolveDir, specifier])
152
- let probe = this.#probes.get(key)
153
- if (probe === undefined) {
119
+ return upsert(this.#probes, JSON.stringify([kind, resolveDir, specifier]), () => {
154
120
  const options = Object.fromEntries(RESOLVE_OPTIONS.filter((k) => initialOptions[k] !== undefined).map((k) => [k, initialOptions[k]]))
155
121
  const cwd = initialOptions.absWorkingDir ?? process.cwd()
156
- probe = esbuild.build({
122
+ return esbuild.build({
157
123
  ...options,
158
124
  stdin: { ...probeSource(specifier, kind), resolveDir },
159
125
  bundle: true,
@@ -166,94 +132,71 @@ export class StasisEsbuild {
166
132
  if (!found?.startsWith('(disabled):')) return null
167
133
  return resolvePath(cwd, found.slice('(disabled):'.length))
168
134
  }, () => null)
169
- this.#probes.set(key, probe)
170
- }
171
- return probe
135
+ })
172
136
  }
173
137
 
174
- // A `browser` field mapping this import to `false` makes esbuild resolve it to an empty "disabled"
175
- // module, but its resolve API drops that flag: a bare name comes back as a namespace-less path, a
176
- // file as its plain path. The file case is confirmed by a probe, and only where a `false` entry
177
- // 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.
178
141
  async #isDisabled(res, specifier, { kind, resolveDir }, build) {
179
142
  if (res.external) return false
180
- if (res.namespace === '') return true // only a disabled bare name resolves to no namespace
143
+ if (res.namespace === '') return true
181
144
  if (res.namespace !== 'file' || (build.initialOptions.platform ?? 'browser') !== 'browser') return false
182
145
  if (!(await this.#mayBeDisabled(res.path, specifier, resolveDir))) return false
183
- // 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.
184
147
  return (await this.#probeDisabled(specifier, kind, resolveDir, build)) === res.path
185
148
  }
186
149
 
187
150
  setup = ({ onResolve, onLoad, onStart, onEnd, resolve, initialOptions, esbuild }) => {
188
- if (!this.#state) return // noop plugin
151
+ if (!this.#state) return
152
+ const emptyModuleURL = pathToFileURL(resolvePath(this.#state.root, EMPTY_MODULE_PATH)).toString()
189
153
 
190
- // Watch/rebuild capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
191
- // changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Rebuilds re-fire
192
- // onStart against the same registrations, so an instance counter catches the second and errors.
154
+ // Rebuilds re-fire onStart against the same registrations.
193
155
  if (!this.#state.config.loadBundle) {
194
156
  onStart(() => {
195
157
  this.#captureBuildCount += 1
196
- if (this.#captureBuildCount === 1) return undefined
197
- return {
198
- errors: [{
199
- text: 'StasisEsbuild: watch/rebuild is not supported for capture -- run a one-shot build (rebuilds would silently attest stale content)',
200
- }],
201
- }
158
+ return this.#captureBuildCount === 1 ? undefined : { errors: [{ text: rebuildRefused('StasisEsbuild') }] }
202
159
  })
203
160
  }
204
161
 
205
- // The preload writes itself (hooks.js); standalone/sidecar States are written on build finish --
206
- // 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.
207
163
  if (this.#state !== State.preload) {
208
164
  onEnd((result) => {
209
- if (result.errors.length > 0) return
210
- this.#state.write()
165
+ if (result.errors.length === 0) this.#state.write()
211
166
  })
212
167
  }
213
168
 
214
- // Load mode: resolution comes from the bundle's import map, not esbuild's resolver -- the file
215
- // may not be on disk. Keep namespace:'file' with the original absolute path (esbuild doesn't stat
216
- // 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.
217
171
  onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
218
172
  if (!this.#state.config.loadBundle) return undefined
219
173
  const isEntry = resolveKind === 'entry-point'
220
174
  let url
221
175
  if (isEntry) {
222
- // Bare/relative entries are anchored to cwd (like the run loader), so the file needn't be on disk.
223
- url = specifier.startsWith('file:')
224
- ? specifier
225
- : 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()
226
178
  if (this.#state.config.full) this.#state.assertEntry(url)
227
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.
228
183
  const parentURL = pathToFileURL(importer).toString()
229
- // Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
230
- // plugin-load, both keyed under `* (with: ...)`. KNOWN GAP: a Node-loader-captured bundle
231
- // keys attributed edges under specific conditions the plugin's `*` lookup won't match.
232
- // The edge is looked up even for a built-in's name: a browser build records `fs` when a
233
- // `browser` field disables it (an edge to the empty module) or a package by that name serves it.
234
- // No attested edge (a built-in, or another external): esbuild's, which externalizes it as
235
- // without us (built-ins under platform:'node'). File BYTES still fail closed at onLoad.
236
184
  const attested = attestedImport(this.#state, parentURL, specifier, { importAttributes: attrs, platform: this.#platform })
237
185
  if (attested === undefined) return undefined
238
186
  ;({ url } = attested)
239
187
  }
240
188
  const format = this.#state.getFormat(url)
241
- const isEmpty = url === pathToFileURL(resolvePath(this.#state.root, EMPTY_MODULE_PATH)).toString()
242
- const kind = isEmpty ? 'empty' : Bundle.isResourceFormat(format) ? 'resource' : 'code'
189
+ const kind = url === emptyModuleURL ? 'empty' : Bundle.isResourceFormat(format) ? 'resource' : 'code'
243
190
  return { path: fileURLToPath(url), namespace: 'file', pluginData: { isEntry, kind } }
244
191
  })
245
192
 
246
193
  onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, with: attrs, ...args }) => {
247
194
  const isEntry = !args.importer
248
-
249
- // 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.
250
196
  const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
251
- // A miss is esbuild's to report -- or to tolerate, as it does a require() in a try/catch (debug's
252
- // 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.
253
198
  if (res.errors.length > 0) return undefined
254
- // A disabled import (`browser` field `false`): returning `res` would bundle the real file or fail
255
- // on a bare name, so decline and let esbuild re-resolve it into its own empty module. Capture
256
- // 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.
257
200
  if (!isEntry && await this.#isDisabled(res, specifier, args, { esbuild, initialOptions })) {
258
201
  if (!this.#state.config.loadBundle) {
259
202
  this.#emptyModule ??= this.#state.addEmptyModule({ reason: 'esbuild' })
@@ -261,8 +204,6 @@ export class StasisEsbuild {
261
204
  }
262
205
  return undefined
263
206
  }
264
- // A non-empty `suffix` (some CSS-modules plugins set one) can't be persisted to the bundle,
265
- // so refuse it rather than silently dropping it at capture.
266
207
  assert.equal(res.suffix, '',
267
208
  `StasisEsbuild: a sibling plugin set res.suffix='${res.suffix}' on '${specifier}'; ` +
268
209
  `stasis can't persist suffixes to the bundle. Disable that plugin or drop suffix from its resolve result.`)
@@ -270,77 +211,46 @@ export class StasisEsbuild {
270
211
  let kind = 'skip'
271
212
  if (res.namespace === 'file' && !res.external) {
272
213
  kind = classifyExtension(res.path, this.#resources)
273
- if (kind === 'unknown') {
274
- return { errors: [{ text: unsupportedExtension('StasisEsbuild', `'${res.path}'`) }] }
275
- }
276
- // Record the edge for BOTH code and resource targets (never entries): load mode resolves
277
- // every specifier through this map, so a resource with no edge would miss at bundle=load and
278
- // fall through to esbuild's disk resolver -- serving the asset UNATTESTED. kind is code|resource here.
279
- if (!isEntry) {
280
- const parentURL = pathToFileURL(args.importer).toString()
281
- const url = pathToFileURL(res.path).toString()
282
- // Forward import attributes symmetrically with load: addImport keys by attributes so
283
- // getImport finds the same edge.
284
- this.#state.addImport(parentURL, specifier, url, { importAttributes: attrs })
285
- }
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 })
286
219
  }
287
-
288
220
  return { ...res, pluginData: { ...res.pluginData, isEntry, kind } }
289
221
  })
290
222
 
291
223
  onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
292
224
  const kind = pluginData?.kind ?? 'skip'
293
225
  if (kind === 'skip') return undefined
294
- // Accept only the `type` import attribute; other attributes aren't persistable to the lockfile yet.
295
- for (const k of Object.keys(attrs)) {
296
- assert.equal(k, 'type', `unsupported import attribute: ${k}`)
297
- }
226
+ assertPersistableAttributes(attrs)
298
227
 
299
- // Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
300
- // 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.
301
229
  if (this.#state.config.loadBundle) {
302
230
  const { source } = this.#state.getFile(pathToFileURL(path).toString())
303
- // A disabled import's stand-in gets esbuild's own loader for disabled modules: `empty` is the
304
- // 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.
305
232
  if (kind === 'empty') return { contents: source, loader: 'empty' }
306
233
  if (kind === 'resource') {
307
- // A loader override for its extension (`stasis build --loader=.png:dataurl`) wins; else the
308
- // resource hook's module, where it builds one.
309
234
  const override = this.#loaders.get(extname(path))
310
235
  if (override) return { contents: source, loader: override }
311
236
  const built = await this.#resource?.(source, { path })
312
237
  if (built) return built
313
- // Else the loader esbuild's own load path takes (the build's `loader` option, else its default
314
- // for the extension), so a resource none loads fails as at capture, not parsed as js.
238
+ // The build's own loader for it, so a resource none loads fails as at capture, not parsed as js.
315
239
  return { contents: source, loader: 'default' }
316
240
  }
317
241
  const loader = this.#loaderFor(path)
318
- // A transform that declines (undefined) serves the attested bytes unchanged.
319
- const transformed = await this.#transform?.(source, { path, loader })
320
- return transformed ?? { contents: source, loader }
242
+ return (await this.#transform?.(source, { path, loader })) ?? { contents: source, loader }
321
243
  }
322
244
 
323
245
  const source = await realReadFile(path)
324
-
325
- if (kind === 'resource') {
326
- // Mark as a resource (State picks 'resource' vs 'resource:base64' from bytes). Return no
327
- // contents/loader: emission is esbuild's job via the build's own `loader` config.
328
- if (!this.#seen.has(path)) {
329
- this.#seen.add(path)
330
- this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'esbuild' })
331
- }
332
- return undefined
333
- }
334
-
335
- // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
336
- assert.ok(isUtf8(source), `StasisEsbuild: code-classified file has non-UTF-8 bytes: ${path}`)
337
-
246
+ const resource = kind === 'resource'
247
+ if (!resource) assertCodeUtf8('StasisEsbuild', path, source)
338
248
  if (!this.#seen.has(path)) {
339
249
  this.#seen.add(path)
340
- 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' })
341
251
  }
342
-
343
- 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) }
344
254
  })
345
255
  }
346
256
  }
@@ -2,30 +2,21 @@ import assert from 'node:assert/strict'
2
2
  import { AsyncLocalStorage } from 'node:async_hooks'
3
3
  import { createRequire } from 'node:module'
4
4
 
5
- // Stands in for the project's Babel transformer (`babelTransformerPath`) under ./metro-transformer.js,
6
- // which swaps it in for each worker transform, to keep each import as WRITTEN in the source. Metro
7
- // names a dependency by the specifier Babel leaves in its output, so a plugin rewriting it (e.g.
8
- // babel-plugin-module-resolver mapping `#local/abc` to `./src/abc`) leaves the serializer only the
9
- // rewrite. This adds one plugin that records every specifier in its `pre` -- Babel runs every
10
- // plugin's `pre` before any plugin's visitor, so nothing has rewritten one yet -- and reads each
11
- // again once Babel is done, before Metro's own passes replace them in the AST, but one importing
12
- // types alone that Babel erased. One a plugin consumed, gone from Babel's output with nothing left
13
- // naming its module (`require('./messages.po')`, which @exodus/i18n's plugin-inline-po reads and
14
- // inlines), it keeps apart. The real transformer runs as it would; its output is untouched.
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.
15
9
 
16
10
  const require = createRequire(import.meta.url)
17
11
 
18
- // One per worker transform, run by ./metro-transformer.js: `babelTransformerPath` is the project's
19
- // (absolute), `asWritten` maps each specifier Babel left to the ones written there, and `consumed`
20
- // holds each one a plugin consumed, as Babel last left it.
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.
21
14
  export const transformContext = new AsyncLocalStorage()
22
15
 
23
- // The nodes Metro's collectDependencies takes a specifier from: these declarations, and the calls
24
- // below (`import()`, `require()`, `__prefetchImport()`, `require.<member>()`).
16
+ // The nodes Metro's collectDependencies takes a specifier from.
25
17
  const DECLARATIONS = new Set(['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'])
26
18
  const REQUIRE_MEMBERS = new Set(['context', 'resolveWeak', 'unstable_importMaybeSync'])
27
19
 
28
- // Whether `node` is one of them.
29
20
  function isDependency(node) {
30
21
  if (node.type !== 'CallExpression') return DECLARATIONS.has(node.type)
31
22
  const { callee } = node
@@ -36,8 +27,7 @@ function isDependency(node) {
36
27
  callee.property.type === 'Identifier' && REQUIRE_MEMBERS.has(callee.property.name)
37
28
  }
38
29
 
39
- // The string `node` holds as a constant: a literal, a template with no substitutions, or a `+` of
40
- // such, which Metro's collectDependencies evaluates to the specifier as it does a literal.
30
+ // A literal, a template with no substitutions, or a `+` of such, which collectDependencies evaluates as a literal.
41
31
  function constantString(node) {
42
32
  if (node?.type === 'StringLiteral') return node.value
43
33
  if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked ?? undefined
@@ -47,25 +37,19 @@ function constantString(node) {
47
37
  return right === undefined ? undefined : left + right
48
38
  }
49
39
 
50
- // The specifier a dependency node holds as a constant: an import/export's source, a call's first
51
- // argument. A plugin rewriting it replaces the literal, never the node holding it.
52
- function specifierOf(node) {
53
- return constantString(node.type === 'CallExpression' ? node.arguments[0] : node.source)
54
- }
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)
55
42
 
56
43
  const TYPE_KINDS = new Set(['type', 'typeof'])
57
44
 
58
- // A declaration of types alone, which Babel may erase, and then it names no module Metro depends on:
59
- // Flow's `import type`/`import typeof`, TypeScript's `import type`, `export type ... from`, and one
60
- // whose every specifier is a type (`import { type A }`, which TypeScript's `onlyRemoveTypeImports`
61
- // keeps as `import {} from`).
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`).
62
47
  function typesOnly(node) {
63
48
  if (TYPE_KINDS.has(node.importKind ?? node.exportKind)) return true
64
49
  const specifiers = node.specifiers ?? []
65
50
  return specifiers.length > 0 && specifiers.every((specifier) => TYPE_KINDS.has(specifier.importKind ?? specifier.exportKind))
66
51
  }
67
52
 
68
- // The dependency nodes in Babel's output, and the specifiers they hold as constants.
69
53
  function dependenciesLeft(t, ast) {
70
54
  const nodes = new Set()
71
55
  const specifiers = new Set()
@@ -92,8 +76,8 @@ function asWrittenPlugin({ types: t }) {
92
76
  if (written !== undefined) slots.push({ node, written, types: node.type !== 'CallExpression' && typesOnly(node) })
93
77
  else if (node.type === 'CallExpression' && node.arguments.length > 0) evaluate = true
94
78
  })
95
- // Metro takes any argument Babel evaluates to a string with confidence, one bound to a constant
96
- // too (`const name = './x'; require(name)`), which only a path, and its scope, can evaluate.
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.
97
81
  if (!evaluate) return
98
82
  file.path.traverse({
99
83
  CallExpression(path) {
@@ -104,10 +88,8 @@ function asWrittenPlugin({ types: t }) {
104
88
  })
105
89
  },
106
90
  visitor: {
107
- // Babel exits a node only if no plugin removed it on the way in, so a declaration of types
108
- // alone that Babel erased never exits: flow-strip-types removes it as it enters it, the TS
109
- // plugin before the traversal gets there. One it kept (`import {} from` under
110
- // `onlyRemoveTypeImports`) exits, still naming its module.
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.
111
93
  'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': {
112
94
  exit(path) {
113
95
  transformContext.getStore().kept.add(path.node)
@@ -123,12 +105,9 @@ export function transform(args) {
123
105
  const slots = []
124
106
  const kept = new Set()
125
107
  Object.assign(context, { slots, kept })
126
- // Once Babel is done: each specifier as it left it, from the node that held it as written, but a
127
- // declaration of types alone Babel erased. One a plugin replaced keeps it too: lowered to a require
128
- // (the commonjs transform), or split in two (`export * as ns from` into an import and an export,
129
- // the export-namespace-from transform), it names the same module, its specifier as rewritten
130
- // before (module-resolver rewrites every one as the program enters). One gone from the output,
131
- // with no node there naming its module, a plugin consumed in its stead.
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.
132
111
  const collect = (result) => {
133
112
  const left = result?.ast && context.types ? dependenciesLeft(context.types, result.ast) : null
134
113
  for (const { node, written, types } of slots) {
@@ -144,7 +123,7 @@ export function transform(args) {
144
123
  }
145
124
  return result
146
125
  }
147
- // Sync or async, as the project's transformer is (the RN one is sync).
126
+ // Sync or async, as the project's transformer is.
148
127
  const result = require(context.babelTransformerPath).transform({ ...args, plugins: [...(args.plugins ?? []), asWrittenPlugin] })
149
128
  return typeof result?.then === 'function' ? result.then(collect) : collect(result)
150
129
  }