Repository navigation
Capture esbuild's resolver metadata for resolution parity #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e97fa40
02a71e4
1a73899
77b91c3
d2c2a85
ac0e9d0
58965d4
cc10d38
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' | ||
|
|
@@ -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. | ||
|
|
@@ -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' | ||
|
|
@@ -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. | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
| 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()]) | ||
|
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' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an upstream resolver and a downstream resolver attach structurally equal 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. | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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) } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.