From 4ad11c303e9fdbbd2dc6198f69abf9e18a8658e5 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Fri, 24 May 2024 18:31:25 -0400 Subject: [PATCH] fix #3639, fix #3646: pass `with` to `onResolve` --- CHANGELOG.md | 31 +++++++++++++++ cmd/esbuild/service.go | 25 +++++++++--- internal/bundler/bundler.go | 11 ++++-- internal/config/config.go | 1 + internal/logger/logger.go | 15 +++++++- lib/shared/common.ts | 13 +++++++ lib/shared/stdio_protocol.ts | 2 + lib/shared/types.ts | 2 + pkg/api/api.go | 2 + pkg/api/api_impl.go | 8 ++-- scripts/plugin-tests.js | 74 +++++++++++++++++++++++++++++++++++- 11 files changed, 168 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 345facf225e..a52ee13baa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,37 @@ function n(){console.log("macOS")}export{n as logPlatform}; ``` +* Pass import attributes to on-resolve plugins ([#3384](https://github.com/evanw/esbuild/issues/3384), [#3639](https://github.com/evanw/esbuild/issues/3639), [#3646](https://github.com/evanw/esbuild/issues/3646)) + + With this release, on-resolve plugins will now have access to the import attributes on the import via the `with` property of the arguments object. This mirrors the `with` property of the arguments object that's already passed to on-load plugins. In addition, you can now pass `with` to the `resolve()` API call which will then forward that value on to all relevant plugins. Here's an example of a plugin that can now be written: + + ```js + const examplePlugin = { + name: 'Example plugin', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + if (args.with.type === 'external') + return { external: true } + }) + } + } + + require('esbuild').build({ + stdin: { + contents: ` + import foo from "./foo" with { type: "external" } + foo() + `, + }, + bundle: true, + format: 'esm', + write: false, + plugins: [examplePlugin], + }).then(result => { + console.log(result.outputFiles[0].text) + }) + ``` + * Formatting support for the `@position-try` rule ([#3773](https://github.com/evanw/esbuild/issues/3773)) Chrome shipped this new CSS at-rule in version 125 as part of the [CSS anchor positioning API](https://developer.chrome.com/blog/anchor-positioning-api). With this release, esbuild now knows to expect a declaration list inside of the `@position-try` body block and will format it appropriately. diff --git a/cmd/esbuild/service.go b/cmd/esbuild/service.go index 485e8299d87..ec217faff2e 100644 --- a/cmd/esbuild/service.go +++ b/cmd/esbuild/service.go @@ -919,6 +919,13 @@ func (service *serviceType) convertPlugins(key int, jsPlugins interface{}, activ if value, ok := request["pluginData"]; ok { options.PluginData = value.(int) } + if value, ok := request["with"]; ok { + value := value.(map[string]interface{}) + options.With = make(map[string]string, len(value)) + for k, v := range value { + options.With[k] = v.(string) + } + } result := build.Resolve(path, options) return encodePacket(packet{ @@ -970,6 +977,11 @@ func (service *serviceType) convertPlugins(key int, jsPlugins interface{}, activ return result, nil } + with := make(map[string]interface{}, len(args.With)) + for k, v := range args.With { + with[k] = v + } + response, ok := service.sendRequest(map[string]interface{}{ "command": "on-resolve", "key": key, @@ -980,6 +992,7 @@ func (service *serviceType) convertPlugins(key int, jsPlugins interface{}, activ "resolveDir": args.ResolveDir, "kind": resolveKindToString(args.Kind), "pluginData": args.PluginData, + "with": with, }).(map[string]interface{}) if !ok { return result, errors.New("The service was stopped") @@ -1055,7 +1068,7 @@ func (service *serviceType) convertPlugins(key int, jsPlugins interface{}, activ return result, nil } - with := make(map[string]interface{}) + with := make(map[string]interface{}, len(args.With)) for k, v := range args.With { with[k] = v } @@ -1266,11 +1279,11 @@ func decodeStringArray(values []interface{}) []string { func encodeOutputFiles(outputFiles []api.OutputFile) []interface{} { values := make([]interface{}, len(outputFiles)) for i, outputFile := range outputFiles { - value := make(map[string]interface{}) - values[i] = value - value["path"] = outputFile.Path - value["contents"] = outputFile.Contents - value["hash"] = outputFile.Hash + values[i] = map[string]interface{}{ + "path": outputFile.Path, + "contents": outputFile.Contents, + "hash": outputFile.Hash, + } } return values } diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index e267f1ee5d1..dd99f7d546f 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -463,6 +463,7 @@ func parseFile(args parseArgs) { record.Range, source.KeyPath, record.Path.Text, + attrs, record.Kind, absResolveDir, pluginData, @@ -865,6 +866,7 @@ func RunOnResolvePlugins( importPathRange logger.Range, importer logger.Path, path string, + importAttributes logger.ImportAttributes, kind ast.ImportKind, absResolveDir string, pluginData interface{}, @@ -875,6 +877,7 @@ func RunOnResolvePlugins( Kind: kind, PluginData: pluginData, Importer: importer, + With: importAttributes, } applyPath := logger.Path{ Text: path, @@ -1057,7 +1060,7 @@ func runOnLoadPlugins( // Reject unsupported import attributes loader := config.LoaderDefault - for _, attr := range source.KeyPath.ImportAttributes.Decode() { + for _, attr := range source.KeyPath.ImportAttributes.DecodeIntoArray() { if attr.Key == "type" { if attr.Value == "json" { loader = config.LoaderWithTypeJSON @@ -1625,6 +1628,7 @@ func (s *scanner) preprocessInjectedFiles() { logger.Range{}, importer, importPath, + logger.ImportAttributes{}, ast.ImportEntryPoint, injectAbsResolveDir, nil, @@ -1804,6 +1808,7 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { logger.Range{}, importer, entryPoint.InputPath, + logger.ImportAttributes{}, ast.ImportEntryPoint, entryPointAbsResolveDir, nil, @@ -2203,7 +2208,7 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann for _, sourceIndex := range sourceIndices { source := &s.results[sourceIndex].file.inputFile.Source - attrs := source.KeyPath.ImportAttributes.Decode() + attrs := source.KeyPath.ImportAttributes.DecodeIntoArray() if len(attrs) == 0 { continue } @@ -2491,7 +2496,7 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann } else { sb.WriteString("]") } - if attrs := result.file.inputFile.Source.KeyPath.ImportAttributes.Decode(); len(attrs) > 0 { + if attrs := result.file.inputFile.Source.KeyPath.ImportAttributes.DecodeIntoArray(); len(attrs) > 0 { sb.WriteString(",\n \"with\": {") for i, attr := range attrs { if i > 0 { diff --git a/internal/config/config.go b/internal/config/config.go index 3dd2e553100..615d6882eaa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -771,6 +771,7 @@ type OnResolveArgs struct { PluginData interface{} Importer logger.Path Kind ast.ImportKind + With logger.ImportAttributes } type OnResolveResult struct { diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 29d1fe9dcc2..8acb9048add 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -272,7 +272,7 @@ type ImportAttribute struct { } // This returns a sorted array instead of a map to make determinism easier -func (attrs ImportAttributes) Decode() (result []ImportAttribute) { +func (attrs ImportAttributes) DecodeIntoArray() (result []ImportAttribute) { if attrs.packedData == "" { return nil } @@ -289,7 +289,20 @@ func (attrs ImportAttributes) Decode() (result []ImportAttribute) { return result } +func (attrs ImportAttributes) DecodeIntoMap() (result map[string]string) { + if array := attrs.DecodeIntoArray(); len(array) > 0 { + result = make(map[string]string, len(array)) + for _, attr := range array { + result[attr.Key] = attr.Value + } + } + return +} + func EncodeImportAttributes(value map[string]string) ImportAttributes { + if len(value) == 0 { + return ImportAttributes{} + } keys := make([]string, 0, len(value)) for k := range value { keys = append(keys, k) diff --git a/lib/shared/common.ts b/lib/shared/common.ts index 9c9e0cfe03b..3bf924b42b5 100644 --- a/lib/shared/common.ts +++ b/lib/shared/common.ts @@ -1249,6 +1249,7 @@ let handlePlugins = async ( let resolveDir = getFlag(options, keys, 'resolveDir', mustBeString) let kind = getFlag(options, keys, 'kind', mustBeString) let pluginData = getFlag(options, keys, 'pluginData', canBeAnything) + let importAttributes = getFlag(options, keys, 'with', mustBeObject) checkForInvalidFlags(options, keys, 'in resolve() call') return new Promise((resolve, reject) => { @@ -1265,6 +1266,7 @@ let handlePlugins = async ( if (kind != null) request.kind = kind else throw new Error(`Must specify "kind" when calling "resolve"`) if (pluginData != null) request.pluginData = details.store(pluginData) + if (importAttributes != null) request.with = sanitizeStringMap(importAttributes, 'with') sendRequest(refs, request, (error, response) => { if (error !== null) reject(new Error(error)) @@ -1382,6 +1384,7 @@ let handlePlugins = async ( resolveDir: request.resolveDir, kind: request.kind, pluginData: details.load(request.pluginData), + with: request.with, }) if (result != null) { @@ -1804,6 +1807,16 @@ function sanitizeStringArray(values: any[], property: string): string[] { return result } +function sanitizeStringMap(map: Record, property: string): Record { + const result: Record = Object.create(null) + for (const key in map) { + const value = map[key] + if (typeof value !== 'string') throw new Error(`key ${quote(key)} in object ${quote(property)} must be a string`) + result[key] = value + } + return result +} + function convertOutputFiles({ path, contents, hash }: protocol.BuildOutputFile): types.OutputFile { // The text is lazily-generated for performance reasons. If no one asks for // it, then it never needs to be generated. diff --git a/lib/shared/stdio_protocol.ts b/lib/shared/stdio_protocol.ts index 92e28ddc2f6..2b14630fa42 100644 --- a/lib/shared/stdio_protocol.ts +++ b/lib/shared/stdio_protocol.ts @@ -170,6 +170,7 @@ export interface ResolveRequest { resolveDir?: string kind?: string pluginData?: number + with?: Record } export interface ResolveResponse { @@ -194,6 +195,7 @@ export interface OnResolveRequest { resolveDir: string kind: types.ImportKind pluginData: number + with: Record } export interface OnResolveResponse { diff --git a/lib/shared/types.ts b/lib/shared/types.ts index df6482ecf93..d5c6ac9efb8 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -340,6 +340,7 @@ export interface ResolveOptions { resolveDir?: string kind?: ImportKind pluginData?: any + with?: Record } /** Documentation: https://esbuild.github.io/plugins/#resolve-results */ @@ -379,6 +380,7 @@ export interface OnResolveArgs { resolveDir: string kind: ImportKind pluginData: any + with: Record } export type ImportKind = diff --git a/pkg/api/api.go b/pkg/api/api.go index 017ed8905c6..a1ea52fd1de 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -576,6 +576,7 @@ type ResolveOptions struct { ResolveDir string Kind ResolveKind PluginData interface{} + With map[string]string } // Documentation: https://esbuild.github.io/plugins/#resolve-results @@ -615,6 +616,7 @@ type OnResolveArgs struct { ResolveDir string Kind ResolveKind PluginData interface{} + With map[string]string } // Documentation: https://esbuild.github.io/plugins/#on-resolve-results diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 18efb58d3b3..843f7f81b87 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -1898,6 +1898,7 @@ func (impl *pluginImpl) onResolve(options OnResolveOptions, callback func(OnReso ResolveDir: args.ResolveDir, Kind: importKindToResolveKind(args.Kind), PluginData: args.PluginData, + With: args.With.DecodeIntoMap(), }) result.PluginName = response.PluginName result.AbsWatchFiles = impl.validatePathsArray(response.WatchFiles, "watch file") @@ -1940,16 +1941,12 @@ func (impl *pluginImpl) onLoad(options OnLoadOptions, callback func(OnLoadArgs) Filter: filter, Namespace: options.Namespace, Callback: func(args config.OnLoadArgs) (result config.OnLoadResult) { - with := make(map[string]string) - for _, attr := range args.Path.ImportAttributes.Decode() { - with[attr.Key] = attr.Value - } response, err := callback(OnLoadArgs{ Path: args.Path.Text, Namespace: args.Path.Namespace, PluginData: args.PluginData, Suffix: args.Path.IgnoredSuffix, - With: with, + With: args.Path.ImportAttributes.DecodeIntoMap(), }) result.PluginName = response.PluginName result.AbsWatchFiles = impl.validatePathsArray(response.WatchFiles, "watch file") @@ -2054,6 +2051,7 @@ func loadPlugins(initialOptions *BuildOptions, fs fs.FS, log logger.Log, caches logger.Range{}, // importPathRange logger.Path{Text: options.Importer, Namespace: options.Namespace}, path, + logger.EncodeImportAttributes(options.With), kind, absResolveDir, options.PluginData, diff --git a/scripts/plugin-tests.js b/scripts/plugin-tests.js index 9a7551c2f48..6458b063181 100644 --- a/scripts/plugin-tests.js +++ b/scripts/plugin-tests.js @@ -2486,7 +2486,46 @@ error: Invalid path suffix "%what" returned from plugin (must start with "?" or assert.strictEqual(result.outputFiles[0].text, 'console.log(/* @__PURE__ */ jay_ess_ex("div", null));\n') }, - async importAttributes({ esbuild }) { + async importAttributesOnResolve({ esbuild }) { + const result = await esbuild.build({ + entryPoints: ['entry'], + bundle: true, + format: 'esm', + charset: 'utf8', + write: false, + plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + if (args.with.type === 'cheese') return { path: 'cheese', namespace: 'ns' } + if (args.with.pizza === 'true') return { path: 'pizza', namespace: 'ns' } + return { path: args.path, namespace: 'ns' } + }) + build.onLoad({ filter: /.*/ }, args => { + const entry = ` + import a from 'foo' with { type: 'cheese' } + import b from 'foo' with { pizza: 'true' } + console.log(a, b) + ` + if (args.path === 'entry') return { contents: entry } + if (args.path === 'cheese') return { contents: `export default "🧀"` } + if (args.path === 'pizza') return { contents: `export default "🍕"` } + }) + }, + }], + }) + assert.strictEqual(result.outputFiles[0].text, `// ns:cheese +var cheese_default = "🧀"; + +// ns:pizza +var pizza_default = "🍕"; + +// ns:entry +console.log(cheese_default, pizza_default); +`) + }, + + async importAttributesOnLoad({ esbuild }) { const result = await esbuild.build({ entryPoints: ['entry'], bundle: true, @@ -2523,6 +2562,39 @@ console.log(foo_default, foo_default2); `) }, + async importAttributesResolve({ esbuild }) { + const log = [] + await esbuild.build({ + entryPoints: [], + bundle: true, + format: 'esm', + charset: 'utf8', + write: false, + plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + log.push(args) + return { external: true } + }) + build.onStart(() => { + build.resolve('foo', { + kind: 'require-call', + with: { type: 'cheese' }, + }) + build.resolve('bar', { + kind: 'import-statement', + with: { pizza: 'true' }, + }) + }) + }, + }], + }) + assert.strictEqual(log.length, 2) + assert.strictEqual(log[0].with.type, 'cheese') + assert.strictEqual(log[1].with.pizza, 'true') + }, + async internalCrashIssue3634({ esbuild }) { await esbuild.build({ entryPoints: [],