Skip to content
Open
123 changes: 106 additions & 17 deletions stasis-plugins/src/esbuild.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isUtf8 } from 'node:buffer'
import { dirname, extname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { isDeepStrictEqual } from 'node:util'
import assert from 'node:assert/strict'

import { Bundle } from '@exodus/stasis-core/bundle'
Expand Down Expand Up @@ -74,6 +75,11 @@ export class StasisEsbuild {
#emptyModule // URL of the empty module disabled imports point at, once capture recorded it
#browserScopes = new Map() // dir -> Promise<[{ dir, keys }]>: `false` keys of it and its ancestors
#probes = new Map() // [kind, resolveDir, specifier] -> Promise<disabled path | null>
#resolving = new Map() // importer pluginData -> (import key -> promise of its capture resolution), while in flight
// Capture declines its resolutions, so no pluginData reaches onLoad; it finds these by path.
#targets = new Map() // path -> 'code' | 'resource', for every file an import or entry resolved to
#entries = new Set() // paths of the entry points
#dataTargets = new Map() // path -> [pluginData] a plugin after this one resolved that target with

// Build starts observed by this instance (across rebuilds and separate build()/context() calls);
// the second is refused in onStart.
Expand Down Expand Up @@ -206,6 +212,8 @@ export class StasisEsbuild {
// Load mode: resolution comes from the bundle's import map, not esbuild's resolver -- the file
// may not be on disk. Keep namespace:'file' with the original absolute path (esbuild doesn't stat
// plugin-returned paths) so output bytes -- banners, asset names, source maps -- match a capture build.
// KNOWN GAP: what esbuild's resolver alone attaches to a file (see the capture hook below) is lost
// here, so a `.js` file of a "type": "module" package doesn't get capture's node-mode interop.
onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
if (!this.#state.config.loadBundle) return undefined
const isEntry = resolveKind === 'entry-point'
Expand Down Expand Up @@ -240,23 +248,39 @@ export class StasisEsbuild {
return { path: fileURLToPath(url), namespace: 'file', pluginData: { isEntry, kind } }
})

onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, with: attrs, ...args }) => {
// Capture: esbuild attaches some metadata only to a resolution its own resolver made, never to a
// plugin's result -- the enclosing package.json "type" (node-mode CommonJS interop, `__toESM(x, 1)`),
// the nearest tsconfig's JSX and TypeScript settings, the "main" fallback of a "module" path. So
// this hook records what build.resolve() finds, then declines: esbuild resolves the import again,
// to the same file, and onLoad attests and serves that file by its path.
const resolveAndRecord = async (specifier, attrs, args) => {
const isEntry = !args.importer

// Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
// Resolve with the arguments esbuild passed us, so the plugins after this one and then esbuild's
// resolver answer as they will once we decline. That assumes a plugin answers the same arguments
// the same way each time: one that doesn't can make esbuild bundle a file other than the one
// recorded, unattested (bundle=load then fails on the recorded one). esbuild can't tell a plugin
// that declines where its import was finally resolved.
const res = await resolve(specifier, { ...args, with: attrs })
// A miss is esbuild's to report -- or to tolerate, as it does a require() in a try/catch (debug's
// optional supports-color). Decline so it re-resolves exactly as without us; no edge.
if (res.errors.length > 0) return undefined
// A disabled import (`browser` field `false`): returning `res` would bundle the real file or fail
// on a bare name, so decline and let esbuild re-resolve it into its own empty module. Capture
// points the edge at the attested empty module, which is what bundle=load serves.
// A disabled import (`browser` field `false`): `res` is the real file, or a bare name esbuild
// can't load, so it must become esbuild's empty `(disabled):` module. Capture points the edge at
// the attested empty module, which is what bundle=load serves.
if (!isEntry && await this.#isDisabled(res, specifier, args, { esbuild, initialOptions })) {
if (!this.#state.config.loadBundle) {
this.#emptyModule ??= this.#state.addEmptyModule({ reason: 'esbuild' })
this.#state.addImport(pathToFileURL(args.importer).toString(), specifier, this.#emptyModule, { importAttributes: attrs })
}
return undefined
if (this.#state.config.loadBundle) return undefined
this.#emptyModule ??= this.#state.addEmptyModule({ reason: 'esbuild' })
this.#state.addImport(pathToFileURL(args.importer).toString(), specifier, this.#emptyModule, { importAttributes: attrs })
// A bare name with no file: decline, and esbuild makes its `(disabled):<name>` module. A file
// can't be declined: esbuild would load it in the 'file' namespace with the same arguments as a
// real import of it (a `browser` map disables a bare name only for its own package's importers),
// which onLoad can't tell apart. So serve the empty module here, under a namespace esbuild prints
// as it prints its own: `(disabled):` and the path, relative unless `absPaths` covers code.
if (res.namespace !== 'file') return undefined
const cwd = initialOptions.absWorkingDir ?? process.cwd()
const path = initialOptions.absPaths?.includes('code') ? res.path : relative(cwd, res.path).replaceAll('\\', '/')
return { path, namespace: '(disabled)', sideEffects: res.sideEffects }
}
// A non-empty `suffix` (some CSS-modules plugins set one) can't be persisted to the bundle,
// so refuse it rather than silently dropping it at capture.
Expand Down Expand Up @@ -286,20 +310,85 @@ export class StasisEsbuild {
}
}

return { ...res, pluginData: { ...res.pluginData, isEntry, kind } }
// Load mode reaches here only for an import the bundle has no edge for: return it, so onLoad
// fails closed on a file the bundle doesn't carry.
if (this.#state.config.loadBundle) return { ...res, pluginData: { ...res.pluginData, isEntry, kind } }
if (kind !== 'skip') {
this.#targets.set(res.path, kind)
if (isEntry) this.#entries.add(res.path)
if (res.pluginData !== undefined) this.#dataTargets.set(res.path, [...this.#dataTargets.get(res.path) ?? [], res.pluginData])
}
return undefined
Comment thread
ChristopherTuring marked this conversation as resolved.
}

onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, with: attrs, ...args }) => {
// The build.resolve() above re-enters this hook with the same arguments: decline that call. They
// are matched in full -- the importer's pluginData by identity, as esbuild hands the nested call
// the very object passed to it -- so an import a plugin could resolve elsewhere is never merged.
// An identical import resolving at the same time (from a file esbuild loads twice, as with and
// without a suffix) can't be told from it, so it declines unrecorded too; onLoad waits for this
// resolution to record the file. (Sorted: the attributes cross esbuild as a Go map, in no set order.)
const key = JSON.stringify([args.importer, args.resolveDir, args.kind, specifier, Object.entries(attrs).toSorted()])
Comment thread
ChristopherTuring marked this conversation as resolved.
let inFlight = this.#resolving.get(args.pluginData)
if (inFlight?.has(key)) return undefined
if (inFlight === undefined) this.#resolving.set(args.pluginData, inFlight = new Map())
const resolution = resolveAndRecord(specifier, attrs, args)
inFlight.set(key, resolution)
try {
return await resolution
} finally {
inFlight.delete(key)
if (inFlight.size === 0) this.#resolving.delete(args.pluginData)
}
})

onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
const kind = pluginData?.kind ?? 'skip'
// The empty module of a disabled file (see above), as esbuild's loader for disabled modules has it.
onLoad({ filter: /$/, namespace: '(disabled)' }, () => ({ contents: '', loader: 'empty' }))

onLoad({ filter: /$/, namespace: 'file' }, async ({ path, suffix, pluginData, with: attrs }) => {
// What onResolve found for this path: by path at capture, as pluginData at load. Any other module
// -- one a plugin before this one resolved -- is left to esbuild and that plugin. A path match
// alone could claim such a module of a recorded file, so at capture: an import capture declined
// never loads with a suffix (it refuses one), and with pluginData only as a plugin after this one
// set it when resolving it -- equal to what capture's build.resolve() got, that plugin answering
// the same way twice. pluginData for a file capture recorded without any is another plugin's (say
// a `?raw` module, or one tagged for a later loader); pluginData for one recorded only with other
// data could be either that or the later plugin answering differently, so it's refused. A module
// capture claims it serves itself, as it attested it: a loader in a plugin after this one never
// sees it, whatever pluginData its resolver set.
const { loadBundle } = this.#state.config
if (!loadBundle && suffix !== '') return undefined
const recorded = () => {
if (pluginData === undefined) return this.#targets.get(path)
const data = this.#dataTargets.get(path)
if (data === undefined) return undefined
return data.some((d) => isDeepStrictEqual(d, pluginData)) ? this.#targets.get(path) : 'mismatch'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track pluginData provenance instead of deep equality

When an upstream resolver and a downstream resolver attach structurally equal pluginData to different modules backed by the same path, this treats the upstream load as one Stasis recorded even though only the downstream resolution was observed. For example, in the new two-tags scenario, if both resolvers independently return { text: true }, the upstream @text/a load passes this equality check, Stasis serves raw JavaScript, and the intended text loader is bypassed instead of the ambiguity failing closed. This is fresh evidence after cc10d38: value equality still conflates unrelated plugin contexts, so the match needs provenance/identity that survives the repeated resolution or must reject this ambiguous case.

Useful? React with 👍 / 👎.

}
let kind = loadBundle ? pluginData?.kind : recorded()
// Not recorded yet: an import that declined as a twin of one still resolving (see onResolve) can
// load first. Wait out the resolutions in flight -- none waits on a load -- and look again.
if ((kind === undefined || kind === 'mismatch') && !loadBundle && this.#resolving.size > 0) {
await Promise.allSettled([...this.#resolving.values()].flatMap((inFlight) => [...inFlight.values()]))
kind = recorded()
}
if (kind === 'mismatch') {
return {
errors: [{
text: `StasisEsbuild: '${path}' loads with pluginData no resolution capture recorded for it -- another plugin's module of the same file, or a plugin after StasisEsbuild resolving it differently the second time; capture can't tell which`,
}],
}
}
kind ??= 'skip'
if (kind === 'skip') return undefined
const isEntry = loadBundle ? pluginData?.isEntry : this.#entries.has(path)
// Accept only the `type` import attribute; other attributes aren't persistable to the lockfile yet.
for (const k of Object.keys(attrs)) {
assert.equal(k, 'type', `unsupported import attribute: ${k}`)
}

// Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
// the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
if (this.#state.config.loadBundle) {
if (loadBundle) {
const { source } = this.#state.getFile(pathToFileURL(path).toString())
// A disabled import's stand-in gets esbuild's own loader for disabled modules: `empty` is the
// one a CSS @import, `composes` or url() accepts, so one empty module serves JS and CSS importers.
Expand All @@ -324,7 +413,7 @@ export class StasisEsbuild {
// contents/loader: emission is esbuild's job via the build's own `loader` config.
if (!this.#seen.has(path)) {
this.#seen.add(path)
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'esbuild' })
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry, resource: true, reason: 'esbuild' })
}
return undefined
}
Expand All @@ -334,7 +423,7 @@ export class StasisEsbuild {

if (!this.#seen.has(path)) {
this.#seen.add(path)
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: 'esbuild' })
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry, reason: 'esbuild' })
}

return { contents: source, loader: this.#loaderFor(path) }
Expand Down
11 changes: 10 additions & 1 deletion tests/esbuild-run.helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import { resolve, join } from 'node:path'
import { rm, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'

import { State } from '@exodus/stasis-core/state'
Expand Down Expand Up @@ -71,6 +72,14 @@ const platform = process.env.STASIS_TEST_ESBUILD_PLATFORM ?? 'node'
// STASIS_TEST_ESBUILD_PLAIN=1 -- build without the plugin: the plain-esbuild baseline a
// capture build's output must match byte-for-byte. Combine with STASIS_TEST_PRELOAD=0.
const plain = process.env.STASIS_TEST_ESBUILD_PLAIN === '1'
// STASIS_TEST_ESBUILD_PLUGINS_BEFORE / _AFTER (JSON arrays) -- modules, relative to the
// cwd, whose default export is an esbuild plugin to run before / after StasisEsbuild
// (or alone, when plain).
const loadPlugins = async (raw) => (raw
? Promise.all(JSON.parse(raw).map(async (m) => (await import(pathToFileURL(resolve(m)).href)).default))
: [])
const pluginsBefore = await loadPlugins(process.env.STASIS_TEST_ESBUILD_PLUGINS_BEFORE)
const pluginsAfter = await loadPlugins(process.env.STASIS_TEST_ESBUILD_PLUGINS_AFTER)
try {
await esbuild.build({
entryPoints: entries.map((e) => resolve(process.cwd(), e)),
Expand All @@ -82,7 +91,7 @@ try {
logLevel: 'silent',
...(loader ? { loader } : {}),
...(external ? { external } : {}),
plugins: plain ? [] : [new StasisEsbuild(pluginOptions)],
plugins: plain ? [...pluginsBefore, ...pluginsAfter] : [...pluginsBefore, new StasisEsbuild(pluginOptions), ...pluginsAfter],
})
} finally {
if (!writeOutput) await rm(dist, { recursive: true, force: true })
Expand Down
Loading
Loading