diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d36cc0e88d..fb9eb0f0dd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,12 @@ jobs: - name: JS API Tests run: node scripts/js-api-tests.js + - name: Plugin Tests + run: node scripts/plugin-tests.js + + - name: TypeScript Type Definition Tests + run: node scripts/ts-type-tests.js + - name: Sucrase Tests if: matrix.os == 'ubuntu-latest' run: make test-sucrase diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1aeec27d2..ca6ad5b5226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,120 @@ # Changelog +## 0.8.2 + +* Fix the omission of `outbase` in the JavaScript API ([#471](https://github.com/evanw/esbuild/pull/471)) + + The original PR for the `outbase` setting added it to the CLI and Go APIs but not the JavaScript API. This release adds it to the JavaScript API too. + +* Fix the TypeScript type definitions ([#499](https://github.com/evanw/esbuild/pull/499)) + + The newly-released `plugins` option in the TypeScript type definitions was incorrectly marked as non-optional. It is now optional. This fix was contributed by [@remorses](https://github.com/remorses). + +## 0.8.1 + +* The initial version of the plugin API ([#111](https://github.com/evanw/esbuild/pull/111)) + + The plugin API lets you inject custom code inside esbuild's build process. You can write plugins in either JavaScript or Go. Right now you can add an "on resolve" callback to determine where import paths go and an "on load" callback to determine what the imported file contains. These two primitives are very powerful, especially in combination with each other. + + Here's a simple example plugin to show off the API in action. Let's say you wanted to enable a workflow where you can import environment variables like this: + + ```js + // app.js + import { NODE_ENV } from 'env' + console.log(`NODE_ENV is ${NODE_ENV}`) + ``` + + This is how you might do that from JavaScript: + + ```js + let envPlugin = { + name: 'env-plugin', + setup(build) { + build.onResolve({ filter: /^env$/ }, args => ({ + path: args.path, + namespace: 'env', + })) + + build.onLoad({ filter: /.*/, namespace: 'env' }, () => ({ + contents: JSON.stringify(process.env), + loader: 'json', + })) + }, + } + + require('esbuild').build({ + entryPoints: ['app.js'], + bundle: true, + outfile: 'out.js', + plugins: [envPlugin], + logLevel: 'info', + }).catch(() => process.exit(1)) + ``` + + This is how you might do that from Go: + + ```go + package main + + import ( + "encoding/json" + "os" + "strings" + + "github.com/evanw/esbuild/pkg/api" + ) + + var envPlugin = api.Plugin{ + Name: "env-plugin", + Setup: func(build api.PluginBuild) { + build.OnResolve(api.OnResolveOptions{Filter: `^env$`}, + func(args api.OnResolveArgs) (api.OnResolveResult, error) { + return api.OnResolveResult{ + Path: args.Path, + Namespace: "env", + }, nil + }) + + build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: "env"}, + func(args api.OnLoadArgs) (api.OnLoadResult, error) { + mappings := make(map[string]string) + for _, item := range os.Environ() { + if equals := strings.IndexByte(item, '='); equals != -1 { + mappings[item[:equals]] = item[equals+1:] + } + } + bytes, _ := json.Marshal(mappings) + contents := string(bytes) + return api.OnLoadResult{ + Contents: &contents, + Loader: api.LoaderJSON, + }, nil + }) + }, + } + + func main() { + result := api.Build(api.BuildOptions{ + EntryPoints: []string{"app.js"}, + Bundle: true, + Outfile: "out.js", + Plugins: []api.Plugin{envPlugin}, + Write: true, + LogLevel: api.LogLevelInfo, + }) + + if len(result.Errors) > 0 { + os.Exit(1) + } + } + ``` + + Comprehensive documentation for the plugin API is not yet available but is coming soon. + +* Add the `outbase` option ([#471](https://github.com/evanw/esbuild/pull/471)) + + Currently, esbuild uses the lowest common ancestor of the entrypoints to determine where to place each entrypoint's output file. This is an excellent default, but is not ideal in some situations. Take for example an app with a folder structure similar to Next.js, with js files at `pages/a/b/c.js` and `pages/a/b/d.js`. These two files correspond to the paths `/a/b/c` and `/a/b/d`. Ideally, esbuild would emit `out/a/b/c.js` and `out/a/b/d.js`. However, esbuild identifies `pages/a/b` as the lowest common ancestor and emits `out/c.js` and `out/d.js`. This release introduces an `--outbase` argument to the cli that allows the user to choose which path to base entrypoint output paths on. With this change, running esbuild with `--outbase=pages` results in the desired behavior. This change was contributed by [@nitsky](https://github.com/nitsky). + ## 0.8.0 **This release contains backwards-incompatible changes.** Since esbuild is before version 1.0.0, these changes have been released as a new minor version to reflect this (as [recommended by npm](https://docs.npmjs.com/misc/semver)). You should either be pinning the exact version of `esbuild` in your `package.json` file or be using a version range syntax that only accepts patch upgrades such as `^0.7.0`. See the documentation about [semver](https://docs.npmjs.com/misc/semver) for more information. diff --git a/Makefile b/Makefile index 1343f1f7cac..d945afca4f9 100644 --- a/Makefile +++ b/Makefile @@ -11,11 +11,11 @@ npm/esbuild-wasm/wasm_exec.js: # These tests are for development test: - make -j5 test-go vet-go verify-source-map end-to-end-tests js-api-tests + make -j6 test-go vet-go verify-source-map end-to-end-tests js-api-tests plugin-tests ts-type-tests # These tests are for release ("test-wasm" is not included in "test" because it's pretty slow) test-all: - make -j6 test-go vet-go verify-source-map end-to-end-tests js-api-tests test-wasm + make -j7 test-go vet-go verify-source-map end-to-end-tests js-api-tests plugin-tests ts-type-tests test-wasm # This includes tests of some 3rd-party libraries, which can be very slow test-extra: test-all test-preact-splitting test-sucrase bench-rome-esbuild test-esprima test-rollup @@ -44,6 +44,12 @@ js-api-tests: cmd/esbuild/version.go | scripts/node_modules cd npm/esbuild && npm version "$(ESBUILD_VERSION)" --allow-same-version node scripts/js-api-tests.js +plugin-tests: cmd/esbuild/version.go | scripts/node_modules + node scripts/plugin-tests.js + +ts-type-tests: | scripts/node_modules + node scripts/ts-type-tests.js + cmd/esbuild/version.go: version.txt node -e 'console.log(`package main\n\nconst esbuildVersion = "$(ESBUILD_VERSION)"`)' > cmd/esbuild/version.go diff --git a/cmd/esbuild/main.go b/cmd/esbuild/main.go index a74b646fedd..0cd14696b1a 100644 --- a/cmd/esbuild/main.go +++ b/cmd/esbuild/main.go @@ -46,6 +46,8 @@ Options: Advanced options: --version Print the current version and exit (` + esbuildVersion + `) + --outbase=... The base path used to determine entry point output + paths (for multiple entry points) --sourcemap=inline Emit the source map with an inline data URL --sourcemap=external Do not link to the source map with a comment --sourcefile=... Set the source file for the source map (for stdin) diff --git a/cmd/esbuild/service.go b/cmd/esbuild/service.go index 26425167907..7a0d0388508 100644 --- a/cmd/esbuild/service.go +++ b/cmd/esbuild/service.go @@ -11,10 +11,13 @@ import ( "io" "io/ioutil" "os" + "regexp" "runtime/debug" "sync" + "github.com/evanw/esbuild/internal/config" "github.com/evanw/esbuild/internal/fs" + "github.com/evanw/esbuild/internal/helpers" "github.com/evanw/esbuild/internal/logger" "github.com/evanw/esbuild/pkg/api" "github.com/evanw/esbuild/pkg/cli" @@ -200,6 +203,7 @@ func encodeErrorPacket(id uint32, err error) []byte { } func (service *serviceType) handleBuildRequest(id uint32, request map[string]interface{}) []byte { + key := request["key"].(int) write := request["write"].(bool) flags := decodeStringArray(request["flags"].([]interface{})) @@ -233,6 +237,181 @@ func (service *serviceType) handleBuildRequest(id uint32, request map[string]int } } + if plugins, ok := request["plugins"]; ok { + plugins := plugins.([]interface{}) + + type filteredCallback struct { + filter *regexp.Regexp + pluginName string + namespace string + id int + } + + var onResolveCallbacks []filteredCallback + var onLoadCallbacks []filteredCallback + + filteredCallbacks := func(pluginName string, kind string, items []interface{}) (result []filteredCallback, err error) { + for _, item := range items { + item := item.(map[string]interface{}) + filter, err := config.CompileFilterForPlugin(pluginName, kind, item["filter"].(string)) + if err != nil { + return nil, err + } + result = append(result, filteredCallback{ + pluginName: pluginName, + id: item["id"].(int), + filter: filter, + namespace: item["namespace"].(string), + }) + } + return + } + + for _, p := range plugins { + p := p.(map[string]interface{}) + pluginName := p["name"].(string) + + if callbacks, err := filteredCallbacks(pluginName, "onResolve", p["onResolve"].([]interface{})); err != nil { + return encodeErrorPacket(id, err) + } else { + onResolveCallbacks = append(onResolveCallbacks, callbacks...) + } + + if callbacks, err := filteredCallbacks(pluginName, "onLoad", p["onLoad"].([]interface{})); err != nil { + return encodeErrorPacket(id, err) + } else { + onLoadCallbacks = append(onLoadCallbacks, callbacks...) + } + } + + // We want to minimize the amount of IPC traffic. Instead of adding one Go + // plugin for every JavaScript plugin, we just add a single Go plugin that + // proxies the plugin queries to the list of JavaScript plugins in the host. + options.Plugins = append(options.Plugins, api.Plugin{ + Name: "JavaScript plugins", + Setup: func(build api.PluginBuild) { + build.OnResolve(api.OnResolveOptions{Filter: ".*"}, func(args api.OnResolveArgs) (api.OnResolveResult, error) { + var ids []interface{} + applyPath := logger.Path{Text: args.Path, Namespace: args.Namespace} + for _, item := range onResolveCallbacks { + if config.PluginAppliesToPath(applyPath, item.filter, item.namespace) { + ids = append(ids, item.id) + } + } + + result := api.OnResolveResult{} + if len(ids) == 0 { + return result, nil + } + + response := service.sendRequest(map[string]interface{}{ + "command": "resolve", + "key": key, + "ids": ids, + "path": args.Path, + "importer": args.Importer, + "namespace": args.Namespace, + "resolveDir": args.ResolveDir, + }).(map[string]interface{}) + + if value, ok := response["id"]; ok { + id := value.(int) + for _, item := range onResolveCallbacks { + if item.id == id { + result.PluginName = item.pluginName + break + } + } + } + if value, ok := response["error"]; ok { + return result, errors.New(value.(string)) + } + if value, ok := response["pluginName"]; ok { + result.PluginName = value.(string) + } + if value, ok := response["path"]; ok { + result.Path = value.(string) + } + if value, ok := response["namespace"]; ok { + result.Namespace = value.(string) + } + if value, ok := response["external"]; ok { + result.External = value.(bool) + } + if value, ok := response["errors"]; ok { + result.Errors = decodeMessages(value.([]interface{})) + } + if value, ok := response["warnings"]; ok { + result.Warnings = decodeMessages(value.([]interface{})) + } + + return result, nil + }) + + build.OnLoad(api.OnLoadOptions{Filter: ".*"}, func(args api.OnLoadArgs) (api.OnLoadResult, error) { + var ids []interface{} + applyPath := logger.Path{Text: args.Path, Namespace: args.Namespace} + for _, item := range onLoadCallbacks { + if config.PluginAppliesToPath(applyPath, item.filter, item.namespace) { + ids = append(ids, item.id) + } + } + + result := api.OnLoadResult{} + if len(ids) == 0 { + return result, nil + } + + response := service.sendRequest(map[string]interface{}{ + "command": "load", + "key": key, + "ids": ids, + "path": args.Path, + "namespace": args.Namespace, + }).(map[string]interface{}) + + if value, ok := response["id"]; ok { + id := value.(int) + for _, item := range onLoadCallbacks { + if item.id == id { + result.PluginName = item.pluginName + break + } + } + } + if value, ok := response["error"]; ok { + return result, errors.New(value.(string)) + } + if value, ok := response["pluginName"]; ok { + result.PluginName = value.(string) + } + if value, ok := response["contents"]; ok { + contents := string(value.([]byte)) + result.Contents = &contents + } + if value, ok := response["resolveDir"]; ok { + result.ResolveDir = value.(string) + } + if value, ok := response["errors"]; ok { + result.Errors = decodeMessages(value.([]interface{})) + } + if value, ok := response["warnings"]; ok { + result.Warnings = decodeMessages(value.([]interface{})) + } + if value, ok := response["loader"]; ok { + loader, err := helpers.ParseLoader(value.(string)) + if err != nil { + return api.OnLoadResult{}, err + } + result.Loader = loader + } + + return result, nil + }) + }, + }) + } + options.Write = write result := api.Build(options) response := map[string]interface{}{ @@ -357,6 +536,35 @@ func encodeMessages(msgs []api.Message) []interface{} { return values } +func decodeMessages(values []interface{}) []api.Message { + msgs := make([]api.Message, len(values)) + for i, value := range values { + obj := value.(map[string]interface{}) + msg := api.Message{Text: obj["text"].(string)} + + // Some messages won't have a location + loc := obj["location"] + if loc != nil { + loc := loc.(map[string]interface{}) + namespace := loc["namespace"].(string) + if namespace == "" { + namespace = "file" + } + msg.Location = &api.Location{ + File: loc["file"].(string), + Namespace: namespace, + Line: loc["line"].(int), + Column: loc["column"].(int), + Length: loc["length"].(int), + LineText: loc["lineText"].(string), + } + } + + msgs[i] = msg + } + return msgs +} + func decodeMessageToPrivate(obj map[string]interface{}) logger.Msg { msg := logger.Msg{Text: obj["text"].(string)} @@ -364,12 +572,17 @@ func decodeMessageToPrivate(obj map[string]interface{}) logger.Msg { loc := obj["location"] if loc != nil { loc := loc.(map[string]interface{}) + namespace := loc["namespace"].(string) + if namespace == "" { + namespace = "file" + } msg.Location = &logger.MsgLocation{ - File: loc["file"].(string), - Line: loc["line"].(int), - Column: loc["column"].(int), - Length: loc["length"].(int), - LineText: loc["lineText"].(string), + File: loc["file"].(string), + Namespace: namespace, + Line: loc["line"].(int), + Column: loc["column"].(int), + Length: loc["length"].(int), + LineText: loc["lineText"].(string), } } diff --git a/cmd/esbuild/version.go b/cmd/esbuild/version.go index 1fdd610e27e..5101fc25c39 100644 --- a/cmd/esbuild/version.go +++ b/cmd/esbuild/version.go @@ -1,3 +1,3 @@ package main -const esbuildVersion = "0.8.0" +const esbuildVersion = "0.8.2" diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index aa39d8bf930..689403f75fe 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -143,28 +143,30 @@ func parseFile(args parseArgs) { var loader config.Loader var absResolveDir string + var pluginName string if stdin := args.options.Stdin; stdin != nil { // Special-case stdin source.Contents = stdin.Contents - source.PrettyPath = "" if stdin.SourceFile != "" { source.PrettyPath = stdin.SourceFile } loader = stdin.Loader - absResolveDir = stdin.AbsResolveDir - } else if args.keyPath.Namespace == "file" { - // Read normal modules from disk - var err error - source.Contents, err = args.fs.ReadFile(args.keyPath.Text) - if err != nil { - if err == syscall.ENOENT { - args.log.AddRangeError(args.importSource, args.importPathRange, - fmt.Sprintf("Could not read from file: %s", args.keyPath.Text)) - } else { - args.log.AddRangeError(args.importSource, args.importPathRange, - fmt.Sprintf("Cannot read file %q: %s", args.res.PrettyPath(args.keyPath), err.Error())) - } + if loader == config.LoaderNone { + loader = config.LoaderJS + } + absResolveDir = args.options.Stdin.AbsResolveDir + } else { + result, ok := runOnLoadPlugins( + args.options.Plugins, + args.res, + args.fs, + args.log, + &source, + args.importSource, + args.importPathRange, + ) + if !ok { if args.inject != nil { args.inject <- config.InjectedFile{ SourceIndex: source.Index, @@ -173,15 +175,18 @@ func parseFile(args parseArgs) { args.results <- parseResult{} return } - loader = loaderFromFileExtension(args.options.ExtensionToLoader, args.fs.Base(args.keyPath.Text)) - absResolveDir = args.fs.Dir(args.keyPath.Text) - } else if source.KeyPath.Namespace == resolver.BrowserFalseNamespace { - // Force disabled modules to be empty - loader = config.LoaderJS + loader = result.loader + absResolveDir = result.absResolveDir + pluginName = result.pluginName } _, base, ext := js_ast.PlatformIndependentPathDirBaseExt(source.KeyPath.Text) + // The special "default" loader determines the loader from the file path + if loader == config.LoaderDefault { + loader = loaderFromFileExtension(args.options.ExtensionToLoader, base+ext) + } + result := parseResult{ file: file{ source: source, @@ -301,7 +306,7 @@ func parseFile(args parseArgs) { default: args.log.AddRangeError(args.importSource, args.importPathRange, - fmt.Sprintf("File could not be loaded: %s", args.prettyPath)) + fmt.Sprintf("File could not be loaded: %s", source.PrettyPath)) } // This must come before we send on the "results" channel to avoid deadlock @@ -361,10 +366,17 @@ func parseFile(args parseArgs) { } // Run the resolver and log an error if the path couldn't be resolved - var resolveResult *resolver.ResolveResult - if absResolveDir != "" { - resolveResult = args.res.Resolve(absResolveDir, record.Path.Text, record.Kind) - } + resolveResult, didLogError := runOnResolvePlugins( + args.options.Plugins, + args.res, + args.log, + args.fs, + &source, + record.Range, + record.Path.Text, + record.Kind, + absResolveDir, + ) cache[record.Path.Text] = resolveResult // All "require.resolve()" imports should be external because we don't @@ -382,7 +394,7 @@ func parseFile(args parseArgs) { // external imports instead of causing errors. This matches a common // code pattern for conditionally importing a module with a graceful // fallback. - if !record.IsInsideTryBody { + if !didLogError && !record.IsInsideTryBody { hint := "" if resolver.IsPackagePath(record.Path.Text) { hint = " (mark it as external to exclude it from the bundle)" @@ -392,6 +404,9 @@ func parseFile(args parseArgs) { hint = " (set platform to \"node\" when building for node)" } } + if absResolveDir == "" && pluginName != "" { + hint = fmt.Sprintf(" (the plugin %q didn't set a resolve directory)", pluginName) + } args.log.AddRangeError(&source, record.Range, fmt.Sprintf("Could not resolve %q%s", record.Path.Text, hint)) } @@ -474,6 +489,208 @@ func extractSourceMapFromComment(log logger.Log, fs fs.FS, res resolver.Resolver return logger.Path{}, nil } +func logPluginMessages( + res resolver.Resolver, + log logger.Log, + name string, + msgs []logger.Msg, + thrown error, + importSource *logger.Source, + importPathRange logger.Range, +) bool { + didLogError := false + + // Report errors and warnings generated by the plugin + for _, msg := range msgs { + if name != "" { + msg.Text = fmt.Sprintf("[%s] %s", name, msg.Text) + } + if msg.Kind == logger.Error { + didLogError = true + } + + // Sanitize the location + if msg.Location != nil { + clone := *msg.Location + if clone.Namespace == "" { + clone.Namespace = "file" + } + if clone.File == "" { + clone.File = importSource.PrettyPath + } else { + clone.File = res.PrettyPath(logger.Path{Text: clone.File, Namespace: clone.Namespace}) + } + msg.Location = &clone + } else { + msg.Location = logger.LocationOrNil(importSource, importPathRange) + } + + log.AddMsg(msg) + } + + // Report errors thrown by the plugin itself + if thrown != nil { + didLogError = true + text := thrown.Error() + if name != "" { + text = fmt.Sprintf("[%s] %s", name, text) + } + log.AddRangeError(importSource, importPathRange, text) + } + + return didLogError +} + +func runOnResolvePlugins( + plugins []config.Plugin, + res resolver.Resolver, + log logger.Log, + fs fs.FS, + importSource *logger.Source, + importPathRange logger.Range, + path string, + kind ast.ImportKind, + absResolveDir string, +) (*resolver.ResolveResult, bool) { + resolverArgs := config.OnResolveArgs{ + Path: path, + Importer: importSource.KeyPath, + ResolveDir: absResolveDir, + } + applyPath := logger.Path{Text: path, Namespace: importSource.KeyPath.Namespace} + + // Apply resolver plugins in order until one succeeds + for _, plugin := range plugins { + for _, onResolve := range plugin.OnResolve { + if !config.PluginAppliesToPath(applyPath, onResolve.Filter, onResolve.Namespace) { + continue + } + + result := onResolve.Callback(resolverArgs) + pluginName := result.PluginName + if pluginName == "" { + pluginName = plugin.Name + } + didLogError := logPluginMessages(res, log, pluginName, result.Msgs, result.ThrownError, importSource, importPathRange) + + // Stop now if there was an error + if didLogError { + return nil, true + } + + // Otherwise, continue on to the next resolver if this loader didn't succeed + if result.Path.Text == "" { + if result.External { + result.Path = logger.Path{Text: path} + } else { + continue + } + } + + return &resolver.ResolveResult{ + PathPair: resolver.PathPair{Primary: result.Path}, + IsExternal: result.External, + }, false + } + } + + // Resolve relative to the resolve directory by default. All paths in the + // "file" namespace automatically have a resolve directory. Loader plugins + // can also configure a custom resolve directory for files in other namespaces. + if absResolveDir != "" { + return res.Resolve(absResolveDir, path, kind), false + } + + return nil, false +} + +type loaderPluginResult struct { + loader config.Loader + absResolveDir string + pluginName string +} + +func runOnLoadPlugins( + plugins []config.Plugin, + res resolver.Resolver, + fs fs.FS, + log logger.Log, + source *logger.Source, + importSource *logger.Source, + importPathRange logger.Range, +) (loaderPluginResult, bool) { + loaderArgs := config.OnLoadArgs{ + Path: source.KeyPath, + } + + // Apply loader plugins in order until one succeeds + for _, plugin := range plugins { + for _, onLoad := range plugin.OnLoad { + if !config.PluginAppliesToPath(source.KeyPath, onLoad.Filter, onLoad.Namespace) { + continue + } + + result := onLoad.Callback(loaderArgs) + pluginName := result.PluginName + if pluginName == "" { + pluginName = plugin.Name + } + didLogError := logPluginMessages(res, log, pluginName, result.Msgs, result.ThrownError, importSource, importPathRange) + + // Stop now if there was an error + if didLogError { + return loaderPluginResult{}, false + } + + // Otherwise, continue on to the next loader if this loader didn't succeed + if result.Contents == nil { + continue + } + + source.Contents = *result.Contents + loader := result.Loader + if loader == config.LoaderNone { + loader = config.LoaderJS + } + if result.AbsResolveDir == "" && source.KeyPath.Namespace == "file" { + result.AbsResolveDir = fs.Dir(source.KeyPath.Text) + } + return loaderPluginResult{ + loader: loader, + absResolveDir: result.AbsResolveDir, + pluginName: pluginName, + }, true + } + } + + // Read normal modules from disk + if source.KeyPath.Namespace == "file" { + if contents, err := fs.ReadFile(source.KeyPath.Text); err == nil { + source.Contents = contents + return loaderPluginResult{ + loader: config.LoaderDefault, + absResolveDir: fs.Dir(source.KeyPath.Text), + }, true + } else if err == syscall.ENOENT { + log.AddRangeError(importSource, importPathRange, + fmt.Sprintf("Could not read from file: %s", source.KeyPath.Text)) + return loaderPluginResult{}, false + } else { + log.AddRangeError(importSource, importPathRange, + fmt.Sprintf("Cannot read file %q: %s", res.PrettyPath(source.KeyPath), err.Error())) + return loaderPluginResult{}, false + } + } + + // Force disabled modules to be empty + if source.KeyPath.Namespace == resolver.BrowserFalseNamespace { + return loaderPluginResult{loader: config.LoaderJS}, true + } + + // Otherwise, fail to load the path + return loaderPluginResult{loader: config.LoaderNone}, true +} + func loaderFromFileExtension(extensionToLoader map[string]config.Loader, base string) config.Loader { // Pick the loader with the longest matching extension. So if there's an // extension for ".css" and for ".module.css", we want to match the one for @@ -710,6 +927,8 @@ func ScanBundle(log logger.Log, fs fs.FS, res resolver.Resolver, entryPaths []st } record.Path.Text = relPath } + } else { + record.Path = path } } } @@ -890,8 +1109,13 @@ func (b *Bundle) Compile(log logger.Log, options config.Options) []OutputFile { options.OutputFormat = config.FormatESModule } - // Determine the lowest common ancestor of all entry points - lcaAbsPath := b.lowestCommonAncestorDirectory(options.CodeSplitting) + // Get the base path from the options or choose the lowest common ancestor of all entry points + var baseAbsPath string + if options.AbsOutputBase != "" { + baseAbsPath = options.AbsOutputBase + } else { + baseAbsPath = b.lowestCommonAncestorDirectory(options.CodeSplitting) + } type linkGroup struct { outputFiles []OutputFile @@ -901,7 +1125,7 @@ func (b *Bundle) Compile(log logger.Log, options config.Options) []OutputFile { var resultGroups []linkGroup if options.CodeSplitting { // If code splitting is enabled, link all entry points together - c := newLinkerContext(&options, log, b.fs, b.res, b.files, b.entryPoints, lcaAbsPath) + c := newLinkerContext(&options, log, b.fs, b.res, b.files, b.entryPoints, baseAbsPath) resultGroups = []linkGroup{{ outputFiles: c.link(), reachableFiles: c.reachableFiles, @@ -913,7 +1137,7 @@ func (b *Bundle) Compile(log logger.Log, options config.Options) []OutputFile { for i, entryPoint := range b.entryPoints { waitGroup.Add(1) go func(i int, entryPoint uint32) { - c := newLinkerContext(&options, log, b.fs, b.res, b.files, []uint32{entryPoint}, lcaAbsPath) + c := newLinkerContext(&options, log, b.fs, b.res, b.files, []uint32{entryPoint}, baseAbsPath) resultGroups[i] = linkGroup{ outputFiles: c.link(), reachableFiles: c.reachableFiles, diff --git a/internal/bundler/bundler_default_test.go b/internal/bundler/bundler_default_test.go index d2198ee6891..d6d1710c2e5 100644 --- a/internal/bundler/bundler_default_test.go +++ b/internal/bundler/bundler_default_test.go @@ -3169,6 +3169,25 @@ func TestInjectImportOrder(t *testing.T) { }) } +func TestOutbase(t *testing.T) { + default_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/a/b/c.js": ` + console.log('c') + `, + "/a/b/d.js": ` + console.log('d') + `, + }, + entryPaths: []string{"/a/b/c.js", "/a/b/d.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + AbsOutputBase: "/", + }, + }) +} + func TestAvoidTDZNoBundle(t *testing.T) { default_suite.expectBundled(t, bundled{ files: map[string]string{ diff --git a/internal/bundler/linker.go b/internal/bundler/linker.go index d17eca57663..c4b50befd30 100644 --- a/internal/bundler/linker.go +++ b/internal/bundler/linker.go @@ -64,7 +64,7 @@ type linkerContext struct { entryPoints []uint32 files []file hasErrors bool - lcaAbsPath string + baseAbsPath string // We should avoid traversing all files in the bundle, because the linker // should be able to run a linking operation on a large bundle where only @@ -305,7 +305,7 @@ func newLinkerContext( res resolver.Resolver, files []file, entryPoints []uint32, - lcaAbsPath string, + baseAbsPath string, ) linkerContext { // Clone information about symbols and files so we don't mutate the input data c := linkerContext{ @@ -317,7 +317,7 @@ func newLinkerContext( files: make([]file, len(files)), symbols: js_ast.NewSymbolMap(len(files)), reachableFiles: findReachableFiles(files, entryPoints), - lcaAbsPath: lcaAbsPath, + baseAbsPath: baseAbsPath, } // Clone various things since we may mutate them later @@ -2388,7 +2388,7 @@ func (c *linkerContext) computeChunks() []chunkInfo { source := file.source if source.KeyPath.Namespace != "file" { baseName = source.IdentifierName - } else if relPath, ok := c.fs.Rel(c.lcaAbsPath, source.KeyPath.Text); ok { + } else if relPath, ok := c.fs.Rel(c.baseAbsPath, source.KeyPath.Text); ok { relDir = c.fs.Dir(relPath) baseName = c.fs.Base(relPath) } else { diff --git a/internal/bundler/snapshots/snapshots_default.txt b/internal/bundler/snapshots/snapshots_default.txt index a60dbb24007..fa140cc1a67 100644 --- a/internal/bundler/snapshots/snapshots_default.txt +++ b/internal/bundler/snapshots/snapshots_default.txt @@ -1370,6 +1370,16 @@ var require_demo_pkg = __commonJS((exports, module) => { const demo_pkg = __toModule(require_demo_pkg()); console.log(demo_pkg.default()); +================================================================================ +TestOutbase +---------- /out/a/b/c.js ---------- +// /a/b/c.js +console.log("c"); + +---------- /out/a/b/d.js ---------- +// /a/b/d.js +console.log("d"); + ================================================================================ TestOutputExtensionRemappingDir ---------- /out/entry.notjs ---------- diff --git a/internal/config/config.go b/internal/config/config.go index 9ccd53f26d6..f34e881d877 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,12 @@ package config import ( + "fmt" + "regexp" + "sync" + "github.com/evanw/esbuild/internal/compat" + "github.com/evanw/esbuild/internal/logger" ) type LanguageTarget int8 @@ -58,6 +63,7 @@ const ( LoaderFile LoaderBinary LoaderCSS + LoaderDefault ) func (loader Loader) IsTypeScript() bool { @@ -185,6 +191,7 @@ type Options struct { AbsOutputFile string AbsOutputDir string + AbsOutputBase string OutputExtensions map[string]string ModuleName []string TsConfigOverride string @@ -194,6 +201,8 @@ type Options struct { InjectAbsPaths []string InjectedFiles []InjectedFile + Plugins []Plugin + // If present, metadata about the bundle is written as JSON here AbsMetadataFile string @@ -213,3 +222,113 @@ func (options *Options) OutputExtensionFor(key string) string { } return key } + +var filterMutex sync.Mutex +var filterCache map[string]*regexp.Regexp + +func compileFilter(filter string) (result *regexp.Regexp) { + if filter == "" { + // Must provide a filter + return nil + } + ok := false + + // Cache hit? + (func() { + filterMutex.Lock() + defer filterMutex.Unlock() + if filterCache != nil { + result, ok = filterCache[filter] + } + })() + if ok { + return + } + + // Cache miss + result, err := regexp.Compile(filter) + if err != nil { + return nil + } + + // Cache for next time + filterMutex.Lock() + defer filterMutex.Unlock() + if filterCache == nil { + filterCache = make(map[string]*regexp.Regexp) + } + filterCache[filter] = result + return +} + +func CompileFilterForPlugin(pluginName string, kind string, filter string) (*regexp.Regexp, error) { + if filter == "" { + return nil, fmt.Errorf("[%s] %q is missing a filter", pluginName, kind) + } + + result := compileFilter(filter) + if result == nil { + return nil, fmt.Errorf("[%s] %q filter is not a valid regular expression: %q", pluginName, kind, filter) + } + + return result, nil +} + +func PluginAppliesToPath(path logger.Path, filter *regexp.Regexp, namespace string) bool { + return (namespace == "" || path.Namespace == namespace) && filter.MatchString(path.Text) +} + +//////////////////////////////////////////////////////////////////////////////// +// Plugin API + +type Plugin struct { + Name string + OnResolve []OnResolve + OnLoad []OnLoad +} + +type OnResolve struct { + Name string + Filter *regexp.Regexp + Namespace string + Callback func(OnResolveArgs) OnResolveResult +} + +type OnResolveArgs struct { + Path string + Importer logger.Path + ResolveDir string +} + +type OnResolveResult struct { + PluginName string + + Path logger.Path + External bool + Namespace string + + Msgs []logger.Msg + ThrownError error +} + +type OnLoad struct { + Name string + Filter *regexp.Regexp + Namespace string + Callback func(OnLoadArgs) OnLoadResult +} + +type OnLoadArgs struct { + Path logger.Path +} + +type OnLoadResult struct { + PluginName string + + Contents *string + AbsResolveDir string + Loader Loader + + Msgs []logger.Msg + ThrownError error +} diff --git a/internal/helpers/api_helpers.go b/internal/helpers/api_helpers.go new file mode 100644 index 00000000000..99976e4da7a --- /dev/null +++ b/internal/helpers/api_helpers.go @@ -0,0 +1,42 @@ +// This package contains internal API-related code that must be shared with +// other internal code outside of the API package. + +package helpers + +import ( + "fmt" + + "github.com/evanw/esbuild/pkg/api" +) + +func ParseLoader(text string) (api.Loader, error) { + switch text { + case "js": + return api.LoaderJS, nil + case "jsx": + return api.LoaderJSX, nil + case "ts": + return api.LoaderTS, nil + case "tsx": + return api.LoaderTSX, nil + case "css": + return api.LoaderCSS, nil + case "json": + return api.LoaderJSON, nil + case "text": + return api.LoaderText, nil + case "base64": + return api.LoaderBase64, nil + case "dataurl": + return api.LoaderDataURL, nil + case "file": + return api.LoaderFile, nil + case "binary": + return api.LoaderBinary, nil + case "default": + return api.LoaderDefault, nil + default: + return api.LoaderNone, fmt.Errorf("Invalid loader: %q (valid: "+ + "js, jsx, ts, tsx, css, json, text, base64, dataurl, file, binary)", text) + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 7d46642e254..3a43fff100d 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -43,11 +43,12 @@ type Msg struct { } type MsgLocation struct { - File string - Line int // 1-based - Column int // 0-based, in bytes - Length int // in bytes - LineText string + File string + Namespace string + Line int // 1-based + Column int // 0-based, in bytes + Length int // in bytes + LineText string } type Loc struct { @@ -523,7 +524,7 @@ loop: return } -func locationOrNil(source *Source, r Range) *MsgLocation { +func LocationOrNil(source *Source, r Range) *MsgLocation { if source == nil { return nil } @@ -708,7 +709,7 @@ func (log Log) AddError(source *Source, loc Loc, text string) { log.AddMsg(Msg{ Kind: Error, Text: text, - Location: locationOrNil(source, Range{Loc: loc}), + Location: LocationOrNil(source, Range{Loc: loc}), }) } @@ -716,7 +717,7 @@ func (log Log) AddWarning(source *Source, loc Loc, text string) { log.AddMsg(Msg{ Kind: Warning, Text: text, - Location: locationOrNil(source, Range{Loc: loc}), + Location: LocationOrNil(source, Range{Loc: loc}), }) } @@ -724,7 +725,7 @@ func (log Log) AddRangeError(source *Source, r Range, text string) { log.AddMsg(Msg{ Kind: Error, Text: text, - Location: locationOrNil(source, r), + Location: LocationOrNil(source, r), }) } @@ -732,6 +733,6 @@ func (log Log) AddRangeWarning(source *Source, r Range, text string) { log.AddMsg(Msg{ Kind: Warning, Text: text, - Location: locationOrNil(source, r), + Location: LocationOrNil(source, r), }) } diff --git a/lib/browser.ts b/lib/browser.ts index 1ccd842f9d0..193fc7e6729 100644 --- a/lib/browser.ts +++ b/lib/browser.ts @@ -62,6 +62,7 @@ export const startService: typeof types.startService = options => { writeToStdin(bytes) { worker.postMessage(bytes) }, + isSync: false, }) return { diff --git a/lib/common.ts b/lib/common.ts index b7ae22e485b..729a3f59f68 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -15,15 +15,21 @@ let mustBeBoolean = (value: boolean | undefined): string | null => let mustBeString = (value: string | undefined): string | null => typeof value === 'string' ? null : 'a string'; +let mustBeRegExp = (value: RegExp | undefined): string | null => + value instanceof RegExp ? null : 'a RegExp object'; + let mustBeInteger = (value: number | undefined): string | null => typeof value === 'number' && value === (value | 0) ? null : 'an integer'; -let mustBeArray = (value: string[] | undefined): string | null => +let mustBeArray = (value: T[] | undefined): string | null => Array.isArray(value) ? null : 'an array'; let mustBeObject = (value: Object | undefined): string | null => typeof value === 'object' && value !== null && !Array.isArray(value) ? null : 'an object'; +let mustBeObjectOrNull = (value: Object | null | undefined): string | null => + typeof value === 'object' && !Array.isArray(value) ? null : 'an object or null'; + let mustBeStringOrBoolean = (value: string | boolean | undefined): string | null => typeof value === 'string' || typeof value === 'boolean' ? null : 'a string or a boolean'; @@ -33,6 +39,9 @@ let mustBeStringOrObject = (value: string | Object | undefined): string | null = let mustBeStringOrArray = (value: string | string[] | undefined): string | null => typeof value === 'string' || Array.isArray(value) ? null : 'a string or an array'; +let mustBeStringOrUint8Array = (value: string | Uint8Array | undefined): string | null => + typeof value === 'string' || value instanceof Uint8Array ? null : 'a string or a Uint8Array'; + type OptionKeys = { [key: string]: boolean }; function getFlag(object: T, keys: OptionKeys, key: K, mustBeFn: (value: T[K]) => string | null): T[K] | undefined { @@ -105,7 +114,8 @@ function pushCommonFlags(flags: string[], options: CommonOptions, keys: OptionKe if (avoidTDZ) flags.push(`--avoid-tdz`); } -function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLevelDefault: types.LogLevel): [string[], boolean, string | null, string | null] { +function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLevelDefault: types.LogLevel): + [string[], boolean, types.Plugin[] | undefined, string | null, string | null] { let flags: string[] = []; let keys: OptionKeys = Object.create(null); let stdinContents: string | null = null; @@ -119,6 +129,7 @@ function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLe let metafile = getFlag(options, keys, 'metafile', mustBeString); let outfile = getFlag(options, keys, 'outfile', mustBeString); let outdir = getFlag(options, keys, 'outdir', mustBeString); + let outbase = getFlag(options, keys, 'outbase', mustBeString); let platform = getFlag(options, keys, 'platform', mustBeString); let tsconfig = getFlag(options, keys, 'tsconfig', mustBeString); let resolveExtensions = getFlag(options, keys, 'resolveExtensions', mustBeArray); @@ -131,6 +142,7 @@ function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLe let entryPoints = getFlag(options, keys, 'entryPoints', mustBeArray); let stdin = getFlag(options, keys, 'stdin', mustBeObject); let write = getFlag(options, keys, 'write', mustBeBoolean) !== false; // Default to true if not specified + let plugins = getFlag(options, keys, 'plugins', mustBeArray); checkForInvalidFlags(options, keys); if (sourcemap) flags.push(`--sourcemap${sourcemap === true ? '' : `=${sourcemap}`}`); @@ -139,6 +151,7 @@ function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLe if (metafile) flags.push(`--metafile=${metafile}`); if (outfile) flags.push(`--outfile=${outfile}`); if (outdir) flags.push(`--outdir=${outdir}`); + if (outbase) flags.push(`--outbase=${outbase}`); if (platform) flags.push(`--platform=${platform}`); if (tsconfig) flags.push(`--tsconfig=${tsconfig}`); if (resolveExtensions) flags.push(`--resolve-extensions=${resolveExtensions.join(',')}`); @@ -181,7 +194,7 @@ function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLe stdinContents = contents ? contents + '' : ''; } - return [flags, write, stdinContents, stdinResolveDir]; + return [flags, write, plugins, stdinContents, stdinResolveDir]; } function flagsForTransformOptions(options: types.TransformOptions, isTTY: boolean, logLevelDefault: types.LogLevel): string[] { @@ -207,6 +220,7 @@ function flagsForTransformOptions(options: types.TransformOptions, isTTY: boolea export interface StreamIn { writeToStdin: (data: Uint8Array) => void; readFileSync?: (path: string, encoding: 'utf8') => string; + isSync: boolean; } export interface StreamOut { @@ -236,11 +250,18 @@ export interface StreamService { ): void; } -// This can't use any promises because it must work for both sync and async code +// This can't use any promises in the main execution flow because it must work +// for both sync and async code. There is an exception for plugin code because +// that can't work in sync code anyway. export function createChannel(streamIn: StreamIn): StreamOut { + type PluginCallback = (request: protocol.OnResolveRequest | protocol.OnLoadRequest) => + Promise; + let responseCallbacks = new Map void>(); + let pluginCallbacks = new Map(); let isClosed = false; let nextRequestID = 0; + let nextBuildKey = 0; // Use a long-lived buffer to store stdout data let stdout = new Uint8Array(16 * 1024); @@ -294,13 +315,24 @@ export function createChannel(streamIn: StreamIn): StreamOut { streamIn.writeToStdin(protocol.encodePacket({ id, isRequest: false, value })); }; - let handleRequest = async (id: number, request: any) => { + let handleRequest = async (id: number, request: protocol.OnResolveRequest | protocol.OnLoadRequest) => { // Catch exceptions in the code below so they get passed to the caller try { - let command = request.command; - switch (command) { + switch (request.command) { + case 'resolve': { + let callback = pluginCallbacks.get(request.key); + sendResponse(id, await callback!(request) as any); + break; + } + + case 'load': { + let callback = pluginCallbacks.get(request.key); + sendResponse(id, await callback!(request) as any); + break; + } + default: - throw new Error(`Invalid command: ` + command); + throw new Error(`Invalid command: ` + (request as any)!.command); } } catch (e) { sendResponse(id, { errors: [await extractErrorMessageV8(e, streamIn)] } as any); @@ -338,6 +370,148 @@ export function createChannel(streamIn: StreamIn): StreamOut { } }; + let handlePlugins = (plugins: types.Plugin[], request: protocol.BuildRequest, buildKey: number) => { + if (streamIn.isSync) throw new Error('Cannot use plugins in synchronous API calls'); + + let onResolveCallbacks: { + [id: number]: (args: types.OnResolveArgs) => + (types.OnResolveResult | null | undefined | Promise) + } = {}; + let onLoadCallbacks: { + [id: number]: (args: types.OnLoadArgs) => + (types.OnLoadResult | null | undefined | Promise) + } = {}; + let nextCallbackID = 0; + let i = 0; + + request.plugins = []; + + for (let item of plugins) { + let name = item.name; + let setup = item.setup; + let plugin: protocol.BuildPlugin = { + name: name + '', + onResolve: [], + onLoad: [], + }; + if (typeof name !== 'string' || name === '') throw new Error(`Plugin at index ${i} is missing a name`); + if (typeof setup !== 'function') throw new Error(`[${plugin.name}] Missing a setup function`); + i++; + + setup({ + onResolve(options, callback) { + let keys: OptionKeys = {}; + let filter = getFlag(options, keys, 'filter', mustBeRegExp); + let namespace = getFlag(options, keys, 'namespace', mustBeString); + checkForInvalidFlags(options, keys); + if (filter == null) throw new Error(`[${plugin.name}] "onResolve" is missing a filter`); + let id = nextCallbackID++; + onResolveCallbacks[id] = callback; + plugin.onResolve.push({ id, filter: filter.source, namespace: namespace || '' }); + }, + + onLoad(options, callback) { + let keys: OptionKeys = {}; + let filter = getFlag(options, keys, 'filter', mustBeRegExp); + let namespace = getFlag(options, keys, 'namespace', mustBeString); + checkForInvalidFlags(options, keys); + if (filter == null) throw new Error(`[${plugin.name}] "onLoad" is missing a filter`); + let id = nextCallbackID++; + onLoadCallbacks[id] = callback; + plugin.onLoad.push({ id, filter: filter.source, namespace: namespace || '' }); + }, + }); + + request.plugins.push(plugin); + } + + pluginCallbacks.set(buildKey, async (request) => { + switch (request.command) { + case 'resolve': { + let response: protocol.OnResolveResponse = {}; + for (let id of request.ids) { + try { + let callback = onResolveCallbacks[id]; + let result = await callback({ + path: request.path, + importer: request.importer, + namespace: request.namespace, + resolveDir: request.resolveDir, + }); + + if (result != null) { + if (typeof result !== 'object') throw new Error('Expected resolver plugin to return an object'); + let keys: OptionKeys = {}; + let pluginName = getFlag(result, keys, 'pluginName', mustBeString); + let path = getFlag(result, keys, 'path', mustBeString); + let namespace = getFlag(result, keys, 'namespace', mustBeString); + let external = getFlag(result, keys, 'external', mustBeBoolean); + let errors = getFlag(result, keys, 'errors', mustBeArray); + let warnings = getFlag(result, keys, 'warnings', mustBeArray); + checkForInvalidFlags(result, keys); + + response.id = id; + if (pluginName != null) response.pluginName = pluginName; + if (path != null) response.path = path; + if (namespace != null) response.namespace = namespace; + if (external != null) response.external = external; + if (errors != null) response.errors = sanitizeMessages(errors); + if (warnings != null) response.warnings = sanitizeMessages(warnings); + break; + } + } catch (e) { + return { id, errors: [await extractErrorMessageV8(e, streamIn)] }; + } + } + return response; + } + + case 'load': { + let response: protocol.OnLoadResponse = {}; + for (let id of request.ids) { + try { + let callback = onLoadCallbacks[id]; + let result = await callback({ + path: request.path, + namespace: request.namespace, + }); + + if (result != null) { + if (typeof result !== 'object') throw new Error('Expected loader plugin to return an object'); + let keys: OptionKeys = {}; + let pluginName = getFlag(result, keys, 'pluginName', mustBeString); + let contents = getFlag(result, keys, 'contents', mustBeStringOrUint8Array); + let resolveDir = getFlag(result, keys, 'resolveDir', mustBeString); + let loader = getFlag(result, keys, 'loader', mustBeString); + let errors = getFlag(result, keys, 'errors', mustBeArray); + let warnings = getFlag(result, keys, 'warnings', mustBeArray); + checkForInvalidFlags(result, keys); + + response.id = id; + if (pluginName != null) response.pluginName = pluginName; + if (contents instanceof Uint8Array) response.contents = contents; + else if (contents != null) response.contents = protocol.encodeUTF8(contents); + if (resolveDir != null) response.resolveDir = resolveDir; + if (loader != null) response.loader = loader; + if (errors != null) response.errors = sanitizeMessages(errors); + if (warnings != null) response.warnings = sanitizeMessages(warnings); + break; + } + } catch (e) { + return { id, errors: [await extractErrorMessageV8(e, streamIn)] }; + } + } + return response; + } + + default: + throw new Error(`Invalid command: ` + (request as any).command); + } + }); + + return () => pluginCallbacks.delete(buildKey); + }; + return { readFromStdout, afterClose, @@ -346,9 +520,12 @@ export function createChannel(streamIn: StreamIn): StreamOut { build(options, isTTY, callback) { const logLevelDefault = 'info'; try { - let [flags, write, stdin, resolveDir] = flagsForBuildOptions(options, isTTY, logLevelDefault); - let request: protocol.BuildRequest = { command: 'build', flags, write, stdin, resolveDir }; + let key = nextBuildKey++; + let [flags, write, plugins, stdin, resolveDir] = flagsForBuildOptions(options, isTTY, logLevelDefault); + let request: protocol.BuildRequest = { command: 'build', key, flags, write, stdin, resolveDir }; + let cleanup = plugins && plugins.length > 0 && handlePlugins(plugins, request, key); sendRequest(request, (error, response) => { + if (cleanup) cleanup(); if (error) return callback(new Error(error), null); let errors = response!.errors; let warnings = response!.warnings; @@ -487,6 +664,7 @@ function extractErrorMessageV8(e: any, streamIn: StreamIn): types.Message { let lineText = contents.split(/\r\n|\r|\n|\u2028|\u2029/)[+match[2] - 1] || '' location = { file: match[1], + namespace: 'file', line: +match[2], column: +match[3] - 1, length: 0, @@ -516,3 +694,42 @@ function failureErrorWithLog(text: string, errors: types.Message[], warnings: ty error.warnings = warnings; return error; } + +function sanitizeMessages(messages: types.PartialMessage[]): types.Message[] { + let messagesClone: types.Message[] = []; + + for (const message of messages) { + let keys: OptionKeys = {}; + let text = getFlag(message, keys, 'text', mustBeString); + let location = getFlag(message, keys, 'location', mustBeObjectOrNull); + checkForInvalidFlags(message, keys); + + let locationClone: types.Message['location'] = null; + if (location != null) { + let keys: OptionKeys = {}; + let file = getFlag(location, keys, 'file', mustBeString); + let namespace = getFlag(location, keys, 'namespace', mustBeString); + let line = getFlag(location, keys, 'line', mustBeInteger); + let column = getFlag(location, keys, 'column', mustBeInteger); + let length = getFlag(location, keys, 'length', mustBeInteger); + let lineText = getFlag(location, keys, 'lineText', mustBeString); + checkForInvalidFlags(location, keys); + + locationClone = { + file: file || '', + namespace: namespace || '', + line: line || 0, + column: column || 0, + length: length || 0, + lineText: lineText || '', + }; + } + + messagesClone.push({ + text: text || '', + location: locationClone, + }); + } + + return messagesClone; +} diff --git a/lib/node.ts b/lib/node.ts index e0709553c4f..2670e6e0a09 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -102,6 +102,7 @@ export let startService: typeof types.startService = options => { child.stdin.write(bytes); }, readFileSync: fs.readFileSync, + isSync: false, }); child.stdout.on('data', readFromStdout); child.stdout.on('end', afterClose); @@ -150,6 +151,7 @@ let runServiceSync = (callback: (service: common.StreamService) => void): void = if (stdin.length !== 0) throw new Error('Must run at most one command'); stdin = bytes; }, + isSync: true, }); callback(service); let stdout = child_process.execFileSync(command, args.concat(`--service=${ESBUILD_VERSION}`), { diff --git a/lib/stdio_protocol.ts b/lib/stdio_protocol.ts index e35658f6339..63030afe1ec 100644 --- a/lib/stdio_protocol.ts +++ b/lib/stdio_protocol.ts @@ -8,10 +8,18 @@ import * as types from "./types"; export interface BuildRequest { command: 'build'; + key: number; flags: string[]; write: boolean; stdin: string | null; resolveDir: string | null; + plugins?: BuildPlugin[]; +} + +export interface BuildPlugin { + name: string; + onResolve: { id: number, filter: string, namespace: string }[]; + onLoad: { id: number, filter: string, namespace: string }[]; } export interface BuildResponse { @@ -38,6 +46,48 @@ export interface TransformResponse { mapFS: boolean; } +export interface OnResolveRequest { + command: 'resolve'; + key: number; + ids: number[]; + path: string; + importer: string; + namespace: string; + resolveDir: string; +} + +export interface OnResolveResponse { + id?: number; + pluginName?: string; + + errors?: types.PartialMessage[]; + warnings?: types.PartialMessage[]; + + path?: string; + external?: boolean; + namespace?: string; +} + +export interface OnLoadRequest { + command: 'load'; + key: number; + ids: number[]; + path: string; + namespace: string; +} + +export interface OnLoadResponse { + id?: number; + pluginName?: string; + + errors?: types.PartialMessage[]; + warnings?: types.PartialMessage[]; + + contents?: Uint8Array; + resolveDir?: string; + loader?: string; +} + //////////////////////////////////////////////////////////////////////////////// export interface Packet { diff --git a/lib/types.ts b/lib/types.ts index ec0d22f832b..2cc791f5492 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,6 +1,6 @@ export type Platform = 'browser' | 'node'; export type Format = 'iife' | 'cjs' | 'esm'; -export type Loader = 'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary'; +export type Loader = 'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary' | 'default'; export type LogLevel = 'info' | 'warning' | 'error' | 'silent'; export type Charset = 'ascii' | 'utf8'; @@ -33,8 +33,8 @@ export interface BuildOptions extends CommonOptions { outfile?: string; metafile?: string; outdir?: string; + outbase?: string; platform?: Platform; - color?: boolean; external?: string[]; loader?: { [ext: string]: Loader }; resolveExtensions?: string[]; @@ -47,6 +47,7 @@ export interface BuildOptions extends CommonOptions { entryPoints?: string[]; stdin?: StdinOptions; + plugins?: Plugin[]; } export interface StdinOptions { @@ -63,6 +64,7 @@ export interface Message { export interface Location { file: string; + namespace: string; line: number; // 1-based column: number; // 0-based, in bytes length: number; // in bytes @@ -109,6 +111,67 @@ export interface TransformFailure extends Error { warnings: Message[]; } +export interface Plugin { + name: string; + setup: (build: PluginBuild) => void; +} + +export interface PluginBuild { + onResolve(options: OnResolveOptions, callback: (args: OnResolveArgs) => + (OnResolveResult | null | undefined | Promise)): void; + onLoad(options: OnLoadOptions, callback: (args: OnLoadArgs) => + (OnLoadResult | null | undefined | Promise)): void; +} + +export interface OnResolveOptions { + filter: RegExp; + namespace?: string; +} + +export interface OnResolveArgs { + path: string; + importer: string; + namespace: string; + resolveDir: string; +} + +export interface OnResolveResult { + pluginName?: string; + + errors?: PartialMessage[]; + warnings?: PartialMessage[]; + + path?: string; + external?: boolean; + namespace?: string; +} + +export interface OnLoadOptions { + filter: RegExp; + namespace?: string; +} + +export interface OnLoadArgs { + path: string; + namespace: string; +} + +export interface OnLoadResult { + pluginName?: string; + + errors?: PartialMessage[]; + warnings?: PartialMessage[]; + + contents?: string | Uint8Array; + resolveDir?: string; + loader?: Loader; +} + +export interface PartialMessage { + text?: string; + location?: Partial | null; +} + // This is the type information for the "metafile" JSON format export interface Metadata { inputs: { diff --git a/npm/esbuild-darwin-64/package.json b/npm/esbuild-darwin-64/package.json index 7e5c77dd672..78b03da2449 100644 --- a/npm/esbuild-darwin-64/package.json +++ b/npm/esbuild-darwin-64/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-darwin-64", - "version": "0.8.0", + "version": "0.8.2", "description": "The macOS 64-bit binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-freebsd-64/package.json b/npm/esbuild-freebsd-64/package.json index 4a6c6cbb016..523b84acf6e 100644 --- a/npm/esbuild-freebsd-64/package.json +++ b/npm/esbuild-freebsd-64/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-freebsd-64", - "version": "0.8.0", + "version": "0.8.2", "description": "The FreeBSD 64-bit binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-freebsd-arm64/package.json b/npm/esbuild-freebsd-arm64/package.json index b7296289481..0d4423f3f76 100644 --- a/npm/esbuild-freebsd-arm64/package.json +++ b/npm/esbuild-freebsd-arm64/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-freebsd-arm64", - "version": "0.8.0", + "version": "0.8.2", "description": "The FreeBSD ARM 64-bit binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-linux-32/package.json b/npm/esbuild-linux-32/package.json index 79ae0e4a2d9..406e017ec06 100644 --- a/npm/esbuild-linux-32/package.json +++ b/npm/esbuild-linux-32/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-linux-32", - "version": "0.8.0", + "version": "0.8.2", "description": "The Linux 32-bit binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-linux-64/package.json b/npm/esbuild-linux-64/package.json index 2bb44469938..890cd11f1f4 100644 --- a/npm/esbuild-linux-64/package.json +++ b/npm/esbuild-linux-64/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-linux-64", - "version": "0.8.0", + "version": "0.8.2", "description": "The Linux 64-bit binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-linux-arm64/package.json b/npm/esbuild-linux-arm64/package.json index 752b5b46682..4c383297a8f 100644 --- a/npm/esbuild-linux-arm64/package.json +++ b/npm/esbuild-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-linux-arm64", - "version": "0.8.0", + "version": "0.8.2", "description": "The Linux ARM 64-bit binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-linux-ppc64le/package.json b/npm/esbuild-linux-ppc64le/package.json index e59f38f2065..d7c482021a1 100644 --- a/npm/esbuild-linux-ppc64le/package.json +++ b/npm/esbuild-linux-ppc64le/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-linux-ppc64le", - "version": "0.8.0", + "version": "0.8.2", "description": "The Linux PowerPC 64-bit Little Endian binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-wasm/package.json b/npm/esbuild-wasm/package.json index 30c5335365a..bc0c7d551ac 100644 --- a/npm/esbuild-wasm/package.json +++ b/npm/esbuild-wasm/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-wasm", - "version": "0.8.0", + "version": "0.8.2", "description": "The cross-platform WebAssembly binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-windows-32/package.json b/npm/esbuild-windows-32/package.json index e2814fd9e94..0418dde9cc8 100644 --- a/npm/esbuild-windows-32/package.json +++ b/npm/esbuild-windows-32/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-windows-32", - "version": "0.8.0", + "version": "0.8.2", "description": "The Windows 32-bit binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild-windows-64/package.json b/npm/esbuild-windows-64/package.json index 11b1610bc59..125d4b65ef1 100644 --- a/npm/esbuild-windows-64/package.json +++ b/npm/esbuild-windows-64/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-windows-64", - "version": "0.8.0", + "version": "0.8.2", "description": "The Windows 64-bit binary for esbuild, a JavaScript bundler.", "repository": "https://github.com/evanw/esbuild", "license": "MIT", diff --git a/npm/esbuild/package.json b/npm/esbuild/package.json index ff7737b8b06..0827e6baafb 100644 --- a/npm/esbuild/package.json +++ b/npm/esbuild/package.json @@ -1,6 +1,6 @@ { "name": "esbuild", - "version": "0.8.0", + "version": "0.8.2", "description": "An extremely fast JavaScript bundler and minifier.", "repository": "https://github.com/evanw/esbuild", "scripts": { diff --git a/pkg/api/api.go b/pkg/api/api.go index 9d6cdda8ee2..6568ce77529 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -102,7 +102,8 @@ const ( type Loader uint8 const ( - LoaderJS Loader = iota + LoaderNone Loader = iota + LoaderJS LoaderJSX LoaderTS LoaderTSX @@ -113,6 +114,7 @@ const ( LoaderFile LoaderBinary LoaderCSS + LoaderDefault ) type Platform uint8 @@ -148,11 +150,12 @@ type Engine struct { } type Location struct { - File string - Line int // 1-based - Column int // 0-based, in bytes - Length int // in bytes - LineText string + File string + Namespace string + Line int // 1-based + Column int // 0-based, in bytes + Length int // in bytes + LineText string } type Message struct { @@ -215,6 +218,7 @@ type BuildOptions struct { Outfile string Metafile string Outdir string + Outbase string Platform Platform Format Format External []string @@ -229,6 +233,7 @@ type BuildOptions struct { EntryPoints []string Stdin *StdinOptions Write bool + Plugins []Plugin } type StdinOptions struct { @@ -296,3 +301,60 @@ type TransformResult struct { func Transform(input string, options TransformOptions) TransformResult { return transformImpl(input, options) } + +//////////////////////////////////////////////////////////////////////////////// +// Plugin API + +type Plugin struct { + Name string + Setup func(PluginBuild) +} + +type PluginBuild interface { + OnResolve(options OnResolveOptions, callback func(OnResolveArgs) (OnResolveResult, error)) + OnLoad(options OnLoadOptions, callback func(OnLoadArgs) (OnLoadResult, error)) +} + +type OnResolveOptions struct { + Filter string + Namespace string +} + +type OnResolveArgs struct { + Path string + Importer string + Namespace string + ResolveDir string +} + +type OnResolveResult struct { + PluginName string + + Errors []Message + Warnings []Message + + Path string + External bool + Namespace string +} + +type OnLoadOptions struct { + Filter string + Namespace string +} + +type OnLoadArgs struct { + Path string + Namespace string +} + +type OnLoadResult struct { + PluginName string + + Errors []Message + Warnings []Message + + Contents *string + ResolveDir string + Loader Loader +} diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 98ab38ae097..6c8f10b158d 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strconv" "strings" "sync" @@ -103,6 +104,8 @@ func validateASCIIOnly(value Charset) bool { func validateLoader(value Loader) config.Loader { switch value { + case LoaderNone: + return config.LoaderNone case LoaderJS: return config.LoaderJS case LoaderJSX: @@ -125,6 +128,8 @@ func validateLoader(value Loader) config.Loader { return config.LoaderBinary case LoaderCSS: return config.LoaderCSS + case LoaderDefault: + return config.LoaderDefault default: panic("Invalid loader") } @@ -386,20 +391,21 @@ func validateOutputExtensions(log logger.Log, outExtensions map[string]string) m return result } -func messagesOfKind(kind logger.MsgKind, msgs []logger.Msg) []Message { +func convertMessagesToPublic(kind logger.MsgKind, msgs []logger.Msg) []Message { var filtered []Message for _, msg := range msgs { if msg.Kind == kind { var location *Location + loc := msg.Location - if msg.Location != nil { - loc := msg.Location + if loc != nil { location = &Location{ - File: loc.File, - Line: loc.Line, - Column: loc.Column, - Length: loc.Length, - LineText: loc.LineText, + File: loc.File, + Namespace: loc.Namespace, + Line: loc.Line, + Column: loc.Column, + Length: loc.Length, + LineText: loc.LineText, } } @@ -412,6 +418,35 @@ func messagesOfKind(kind logger.MsgKind, msgs []logger.Msg) []Message { return filtered } +func convertMessagesToInternal(msgs []logger.Msg, kind logger.MsgKind, messages []Message) []logger.Msg { + for _, message := range messages { + var location *logger.MsgLocation + loc := message.Location + + if loc != nil { + namespace := loc.Namespace + if namespace == "" { + namespace = "file" + } + location = &logger.MsgLocation{ + File: loc.File, + Namespace: namespace, + Line: loc.Line, + Column: loc.Column, + Length: loc.Length, + LineText: loc.LineText, + } + } + + msgs = append(msgs, logger.Msg{ + Kind: kind, + Text: message.Text, + Location: location, + }) + } + return msgs +} + //////////////////////////////////////////////////////////////////////////////// // Build API @@ -445,6 +480,7 @@ func buildImpl(buildOpts BuildOptions) BuildResult { OutputFormat: validateFormat(buildOpts.Format), AbsOutputFile: validatePath(log, realFS, buildOpts.Outfile), AbsOutputDir: validatePath(log, realFS, buildOpts.Outdir), + AbsOutputBase: validatePath(log, realFS, buildOpts.Outbase), AbsMetadataFile: validatePath(log, realFS, buildOpts.Metafile), OutputExtensions: validateOutputExtensions(log, buildOpts.OutExtensions), ExtensionToLoader: validateLoaders(log, buildOpts.Loader), @@ -537,6 +573,8 @@ func buildImpl(buildOpts BuildOptions) BuildResult { log.AddError(nil, logger.Loc{}, "Splitting currently only works with the \"esm\" format") } + loadPlugins(&options, realFS, log, buildOpts.Plugins) + var outputFiles []OutputFile // Stop now if there were errors @@ -607,8 +645,8 @@ func buildImpl(buildOpts BuildOptions) BuildResult { msgs := log.Done() return BuildResult{ - Errors: messagesOfKind(logger.Error, msgs), - Warnings: messagesOfKind(logger.Warning, msgs), + Errors: convertMessagesToPublic(logger.Error, msgs), + Warnings: convertMessagesToPublic(logger.Warning, msgs), OutputFiles: outputFiles, } } @@ -655,6 +693,14 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult } } + // Apply default values + if transformOpts.Sourcefile == "" { + transformOpts.Sourcefile = "" + } + if transformOpts.Loader == LoaderNone { + transformOpts.Loader = LoaderJS + } + // Convert and validate the transformOpts jsFeatures, cssFeatures := validateFeatures(log, transformOpts.Target, transformOpts.Engines) options := config.Options{ @@ -727,9 +773,145 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult msgs := log.Done() return TransformResult{ - Errors: messagesOfKind(logger.Error, msgs), - Warnings: messagesOfKind(logger.Warning, msgs), + Errors: convertMessagesToPublic(logger.Error, msgs), + Warnings: convertMessagesToPublic(logger.Warning, msgs), Code: code, Map: sourceMap, } } + +//////////////////////////////////////////////////////////////////////////////// +// Plugin API + +type pluginImpl struct { + log logger.Log + fs fs.FS + plugin config.Plugin +} + +func (impl *pluginImpl) OnResolve(options OnResolveOptions, callback func(OnResolveArgs) (OnResolveResult, error)) { + filter, err := config.CompileFilterForPlugin(impl.plugin.Name, "OnResolve", options.Filter) + if filter == nil { + impl.log.AddError(nil, logger.Loc{}, err.Error()) + return + } + + impl.plugin.OnResolve = append(impl.plugin.OnResolve, config.OnResolve{ + Name: impl.plugin.Name, + Filter: filter, + Namespace: options.Namespace, + Callback: func(args config.OnResolveArgs) (result config.OnResolveResult) { + response, err := callback(OnResolveArgs{ + Path: args.Path, + Importer: args.Importer.Text, + Namespace: args.Importer.Namespace, + ResolveDir: args.ResolveDir, + }) + result.PluginName = response.PluginName + + if err != nil { + result.ThrownError = err + return + } + + if response.Namespace == "" { + response.Namespace = "file" + } + result.Path = logger.Path{Text: response.Path, Namespace: response.Namespace} + result.External = response.External + + // Convert log messages + if len(response.Errors)+len(response.Warnings) > 0 { + msgs := make(sortableMsgs, 0, len(response.Errors)+len(response.Warnings)) + msgs = convertMessagesToInternal(msgs, logger.Error, response.Errors) + msgs = convertMessagesToInternal(msgs, logger.Warning, response.Warnings) + sort.Sort(msgs) + result.Msgs = msgs + } + return + }, + }) +} + +func (impl *pluginImpl) OnLoad(options OnLoadOptions, callback func(OnLoadArgs) (OnLoadResult, error)) { + filter, err := config.CompileFilterForPlugin(impl.plugin.Name, "OnLoad", options.Filter) + if filter == nil { + impl.log.AddError(nil, logger.Loc{}, err.Error()) + return + } + + impl.plugin.OnLoad = append(impl.plugin.OnLoad, config.OnLoad{ + Filter: filter, + Namespace: options.Namespace, + Callback: func(args config.OnLoadArgs) (result config.OnLoadResult) { + response, err := callback(OnLoadArgs{ + Path: args.Path.Text, + Namespace: args.Path.Namespace, + }) + result.PluginName = response.PluginName + + if err != nil { + result.ThrownError = err + return + } + + result.Contents = response.Contents + result.Loader = validateLoader(response.Loader) + if absPath := validatePath(impl.log, impl.fs, response.ResolveDir); absPath != "" { + result.AbsResolveDir = absPath + } + + // Convert log messages + if len(response.Errors)+len(response.Warnings) > 0 { + msgs := make(sortableMsgs, 0, len(response.Errors)+len(response.Warnings)) + msgs = convertMessagesToInternal(msgs, logger.Error, response.Errors) + msgs = convertMessagesToInternal(msgs, logger.Warning, response.Warnings) + sort.Sort(msgs) + result.Msgs = msgs + } + return + }, + }) +} + +// This type is just so we can use Go's native sort function +type sortableMsgs []logger.Msg + +func (a sortableMsgs) Len() int { return len(a) } +func (a sortableMsgs) Swap(i int, j int) { a[i], a[j] = a[j], a[i] } + +func (a sortableMsgs) Less(i int, j int) bool { + ai := a[i].Location + aj := a[j].Location + if ai == nil || aj == nil { + return ai == nil && aj != nil + } + if ai.File != aj.File { + return ai.File < aj.File + } + if ai.Line != aj.Line { + return ai.Line < aj.Line + } + if ai.Column != aj.Column { + return ai.Column < aj.Column + } + return a[i].Text < a[j].Text +} + +func loadPlugins(options *config.Options, fs fs.FS, log logger.Log, plugins []Plugin) { + for i, item := range plugins { + if item.Name == "" { + log.AddError(nil, logger.Loc{}, fmt.Sprintf("Plugin at index %d is missing a name", i)) + continue + } + + impl := &pluginImpl{ + fs: fs, + log: log, + plugin: config.Plugin{Name: item.Name}, + } + + item.Setup(impl) + options.Plugins = append(options.Plugins, impl.plugin) + } +} diff --git a/pkg/cli/cli_impl.go b/pkg/cli/cli_impl.go index 997038621e8..944c249bda4 100644 --- a/pkg/cli/cli_impl.go +++ b/pkg/cli/cli_impl.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/evanw/esbuild/internal/helpers" "github.com/evanw/esbuild/internal/logger" "github.com/evanw/esbuild/pkg/api" ) @@ -152,6 +153,9 @@ func parseOptionsImpl(osArgs []string, buildOpts *api.BuildOptions, transformOpt case strings.HasPrefix(arg, "--outdir=") && buildOpts != nil: buildOpts.Outdir = arg[len("--outdir="):] + case strings.HasPrefix(arg, "--outbase=") && buildOpts != nil: + buildOpts.Outbase = arg[len("--outbase="):] + case strings.HasPrefix(arg, "--tsconfig=") && buildOpts != nil: buildOpts.Tsconfig = arg[len("--tsconfig="):] @@ -185,7 +189,7 @@ func parseOptionsImpl(osArgs []string, buildOpts *api.BuildOptions, transformOpt return fmt.Errorf("Missing \"=\": %q", value) } ext, text := value[:equals], value[equals+1:] - loader, err := parseLoader(text) + loader, err := helpers.ParseLoader(text) if err != nil { return err } @@ -193,7 +197,7 @@ func parseOptionsImpl(osArgs []string, buildOpts *api.BuildOptions, transformOpt case strings.HasPrefix(arg, "--loader="): value := arg[len("--loader="):] - loader, err := parseLoader(value) + loader, err := helpers.ParseLoader(value) if err != nil { return err } @@ -416,36 +420,6 @@ outer: return } -func parseLoader(text string) (api.Loader, error) { - switch text { - case "js": - return api.LoaderJS, nil - case "jsx": - return api.LoaderJSX, nil - case "ts": - return api.LoaderTS, nil - case "tsx": - return api.LoaderTSX, nil - case "css": - return api.LoaderCSS, nil - case "json": - return api.LoaderJSON, nil - case "text": - return api.LoaderText, nil - case "base64": - return api.LoaderBase64, nil - case "dataurl": - return api.LoaderDataURL, nil - case "file": - return api.LoaderFile, nil - case "binary": - return api.LoaderBinary, nil - default: - return 0, fmt.Errorf("Invalid loader: %q (valid: "+ - "js, jsx, ts, tsx, css, json, text, base64, dataurl, file, binary)", text) - } -} - // This returns either BuildOptions, TransformOptions, or an error func parseOptionsForRun(osArgs []string) (*api.BuildOptions, *api.TransformOptions, error) { // If there's an entry point or we're bundling, then we're building diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index 0672d9ca174..eaf3c846bd1 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -635,6 +635,22 @@ console.log("success"); assert.strictEqual(contents.indexOf('=>'), -1) assert.strictEqual(contents.indexOf('const'), -1) }, + + async outbase({ esbuild, testDir }) { + const outbase = path.join(testDir, 'pages') + const b = path.join(outbase, 'a', 'b', 'index.js') + const c = path.join(outbase, 'a', 'c', 'index.js') + const outdir = path.join(testDir, 'outdir') + await mkdirAsync(path.dirname(b), { recursive: true }) + await mkdirAsync(path.dirname(c), { recursive: true }) + await writeFileAsync(b, 'module.exports = "b"') + await writeFileAsync(c, 'module.exports = "c"') + await esbuild.build({ entryPoints: [b, c], outdir, outbase, format: 'cjs' }) + const outB = path.join(outdir, path.relative(outbase, b)) + const outC = path.join(outdir, path.relative(outbase, c)) + assert.strictEqual(require(outB), 'b') + assert.strictEqual(require(outC), 'c') + }, } async function futureSyntax(service, js, targetBelow, targetAbove) { diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 27b4db962e6..a2da19d9883 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -2,6 +2,11 @@ "requires": true, "lockfileVersion": 1, "dependencies": { + "@types/node": { + "version": "14.14.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.6.tgz", + "integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==" + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -114,6 +119,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "typescript": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.5.tgz", + "integrity": "sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==" + }, "unicode-13.0.0": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/unicode-13.0.0/-/unicode-13.0.0-0.8.0.tgz", diff --git a/scripts/package.json b/scripts/package.json index 74a23e84ba0..4e6dc889d4c 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -1,8 +1,10 @@ { "dependencies": { + "@types/node": "14.14.6", "js-yaml": "3.14.0", "rimraf": "3.0.2", "source-map": "0.7.3", + "typescript": "4.0.5", "unicode-13.0.0": "0.8.0" } } diff --git a/scripts/plugin-tests.js b/scripts/plugin-tests.js new file mode 100644 index 00000000000..30c822adbf1 --- /dev/null +++ b/scripts/plugin-tests.js @@ -0,0 +1,559 @@ +const { installForTests } = require('./esbuild') +const rimraf = require('rimraf') +const assert = require('assert') +const path = require('path') +const util = require('util') +const fs = require('fs') + +const readFileAsync = util.promisify(fs.readFile) +const writeFileAsync = util.promisify(fs.writeFile) +const mkdirAsync = util.promisify(fs.mkdir) + +const repoDir = path.dirname(__dirname) +const rootTestDir = path.join(repoDir, 'scripts', '.plugin-tests') + +let pluginTests = { + async noPluginsWithBuildSync({ esbuild }) { + try { + esbuild.buildSync({ + entryPoints: [], logLevel: 'silent', plugins: [{ + name: 'name', + setup() { }, + }], + }) + throw new Error('Expected an error to be thrown') + } catch (e) { + assert.strictEqual(e.message, 'Cannot use plugins in synchronous API calls') + } + }, + + async emptyArray({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, `export default 123`) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + }, + + async emptyArrayWithBuildSync({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, `export default 123`) + esbuild.buildSync({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + }, + + async basicLoader({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const custom = path.join(testDir, 'example.custom') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import x from './example.custom' + export default x + `) + await writeFileAsync(custom, ``) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onLoad({ filter: /\.custom$/ }, args => { + assert.strictEqual(args.path, custom) + return { contents: 'this is custom', loader: 'text' } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 'this is custom') + }, + + async basicResolver({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const custom = path.join(testDir, 'example.txt') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import x from 'test' + export default x + `) + await writeFileAsync(custom, `example text`) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /^test$/ }, args => { + assert.strictEqual(args.path, 'test') + return { path: custom } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 'example text') + }, + + async fibonacciResolverMemoized({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import x from 'fib(10)' + export default x + `) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /^fib\((\d+)\)$/ }, args => { + return { path: args.path, namespace: 'fib' } + }) + build.onLoad({ filter: /^fib\((\d+)\)$/, namespace: 'fib' }, args => { + let match = /^fib\((\d+)\)$/.exec(args.path), n = +match[1] + let contents = n < 2 ? `export default ${n}` : ` + import n1 from 'fib(${n - 1})' + import n2 from 'fib(${n - 2})' + export default n1 + n2` + return { contents } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 55) + }, + + async fibonacciResolverNotMemoized({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import x from 'fib(10)' + export default x + `) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /^fib\((\d+)\)/ }, args => { + return { path: args.path, namespace: 'fib' } + }) + build.onLoad({ filter: /^fib\((\d+)\)/, namespace: 'fib' }, args => { + let match = /^fib\((\d+)\)/.exec(args.path), n = +match[1] + let contents = n < 2 ? `export default ${n}` : ` + import n1 from 'fib(${n - 1}) ${args.path}' + import n2 from 'fib(${n - 2}) ${args.path}' + export default n1 + n2` + return { contents } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 55) + }, + + async resolversCalledInSequence({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const nested = path.join(testDir, 'nested.js') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import x from 'test' + export default x + `) + await writeFileAsync(nested, ` + export default 123 + `) + let trace = [] + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [ + { + name: 'plugin1', + setup(build) { + build.onResolve({ filter: /^.*$/ }, () => { trace.push('called first') }) + }, + }, + { + name: 'plugin2', + setup(build) { + build.onResolve({ filter: /^ignore me$/ }, () => { trace.push('not called') }) + }, + }, + { + name: 'plugin3', + setup(build) { + build.onResolve({ filter: /^.*$/ }, () => { + trace.push('called second') + return { path: nested } + }) + }, + }, + { + name: 'plugin4', + setup(build) { + build.onResolve({ filter: /^.*$/ }, () => { trace.push('not called') }) + }, + } + ], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + assert.deepStrictEqual(trace, [ + 'called first', + 'called second', + ]) + }, + + async loadersCalledInSequence({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const nested = path.join(testDir, 'nested.js') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import x from './nested.js' + export default x + `) + await writeFileAsync(nested, ` + export default 123 + `) + let trace = [] + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [ + { + name: 'plugin1', + setup(build) { + build.onLoad({ filter: /^.*$/ }, () => { trace.push('called first') }) + }, + }, + { + name: 'plugin2', + setup(build) { + build.onLoad({ filter: /^.*$/, namespace: 'ignore-me' }, () => { trace.push('not called') }) + }, + }, + { + name: 'plugin3', + setup(build) { + build.onLoad({ filter: /^.*$/, namespace: 'file' }, () => { + trace.push('called second') + return { contents: 'export default "abc"' } + }) + }, + }, + { + name: 'plugin4', + setup(build) { + build.onLoad({ filter: /^.*$/, namespace: 'file' }, () => { trace.push('not called') }) + }, + }, + ], + }) + const result = require(output) + assert.strictEqual(result.default, 'abc') + assert.deepStrictEqual(trace, [ + 'called first', + 'called second', + ]) + }, + + async httpRelative({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import x from 'http://example.com/assets/js/example.js' + export default x + `) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /^http:\/\// }, args => { + return { path: args.path, namespace: 'http' } + }) + build.onResolve({ filter: /.*/, namespace: 'http' }, args => { + return { path: new URL(args.path, args.importer).toString(), namespace: 'http' } + }) + build.onLoad({ filter: /^http:\/\//, namespace: 'http' }, args => { + switch (args.path) { + case 'http://example.com/assets/js/example.js': + return { contents: `import y from './data/base.js'; export default y` } + case 'http://example.com/assets/js/data/base.js': + return { contents: `export default 123` } + } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + }, + + async rewriteExternal({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import {exists} from 'extern' + export default exists + `) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /^extern$/ }, () => { + return { path: 'fs', external: true, namespace: 'for-testing' } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, fs.exists) + }, + + async resolveDirInFileModule({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + const example = path.join(testDir, 'example.custom') + const resolveDir = path.join(testDir, 'target') + const loadme = path.join(resolveDir, 'loadme.js') + await mkdirAsync(resolveDir) + await writeFileAsync(input, ` + import value from './example.custom' + export default value + `) + await writeFileAsync(example, ` + export {default} from './loadme' + `) + await writeFileAsync(loadme, ` + export default 123 + `) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onLoad({ filter: /\.custom$/ }, async (args) => { + return { contents: await readFileAsync(args.path), resolveDir } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + }, + + async noResolveDirInFileModule({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + const example = path.join(testDir, 'example.custom') + const resolveDir = path.join(testDir, 'target') + const loadme = path.join(resolveDir, 'loadme.js') + await mkdirAsync(resolveDir) + await writeFileAsync(input, ` + import value from './example.custom' + export default value + `) + await writeFileAsync(example, ` + export {default} from './target/loadme' + `) + await writeFileAsync(loadme, ` + export default 123 + `) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onLoad({ filter: /\.custom$/ }, async (args) => { + return { contents: await readFileAsync(args.path) } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + }, + + async resolveDirInVirtualModule({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + const resolveDir = path.join(testDir, 'target') + const loadme = path.join(resolveDir, 'loadme.js') + await mkdirAsync(resolveDir) + await writeFileAsync(input, ` + import value from 'virtual' + export default value + `) + await writeFileAsync(loadme, ` + export default 123 + `) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + let contents = `export {default} from './loadme'` + build.onResolve({ filter: /^virtual$/ }, () => ({ path: 'virtual', namespace: 'for-testing' })) + build.onLoad({ filter: /.*/, namespace: 'for-testing' }, () => ({ contents, resolveDir })) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + }, + + async noResolveDirInVirtualModule({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const output = path.join(testDir, 'out.js') + const resolveDir = path.join(testDir, 'target') + const loadme = path.join(resolveDir, 'loadme.js') + await mkdirAsync(resolveDir) + await writeFileAsync(input, ` + import value from 'virtual' + export default value + `) + await writeFileAsync(loadme, ` + export default 123 + `) + let error + try { + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', logLevel: 'silent', plugins: [{ + name: 'name', + setup(build) { + let contents = `export {default} from './loadme'` + build.onResolve({ filter: /^virtual$/ }, () => ({ path: 'virtual', namespace: 'for-testing' })) + build.onLoad({ filter: /.*/, namespace: 'for-testing' }, () => ({ contents })) + }, + }], + }) + } catch (e) { + error = e + } + assert.notStrictEqual(error, void 0) + if (!Array.isArray(error.errors)) throw error + assert.strictEqual(error.errors.length, 1) + assert.strictEqual(error.errors[0].text, `Could not resolve "./loadme" (the plugin "name" didn't set a resolve directory)`) + }, + + async webAssembly({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + const wasm = path.join(testDir, 'test.wasm') + const output = path.join(testDir, 'out.js') + await writeFileAsync(input, ` + import load from './test.wasm' + export default async (x, y) => (await load()).add(x, y) + `) + await writeFileAsync(wasm, Buffer.of( + // #[wasm_bindgen] + // pub fn add(x: i32, y: i32) -> i32 { x + y } + 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x01, 0x60, + 0x02, 0x7F, 0x7F, 0x01, 0x7F, 0x03, 0x02, 0x01, 0x00, 0x05, 0x03, 0x01, + 0x00, 0x11, 0x07, 0x10, 0x02, 0x06, 0x6D, 0x65, 0x6D, 0x6F, 0x72, 0x79, + 0x02, 0x00, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x0A, 0x09, 0x01, 0x07, + 0x00, 0x20, 0x00, 0x20, 0x01, 0x6A, 0x0B, + )) + await esbuild.build({ + entryPoints: [input], bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /\.wasm$/ }, args => ({ + path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path), + namespace: args.namespace === 'wasm-stub' ? 'wasm-binary' : 'wasm-stub', + })) + build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => + ({ contents: await readFileAsync(args.path), loader: 'binary' })) + build.onLoad({ filter: /.*/, namespace: 'wasm-stub' }, async (args) => ({ + contents: `import wasm from ${JSON.stringify(args.path)} + export default async (imports) => + (await WebAssembly.instantiate(wasm, imports)).instance.exports` })) + }, + }], + }) + const result = require(output) + assert.strictEqual(await result.default(103, 20), 123) + }, + + async stdinRelative({ esbuild, testDir }) { + const output = path.join(testDir, 'out.js') + await esbuild.build({ + stdin: { contents: `import x from "./stdinRelative.js"; export default x` }, + bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + assert.strictEqual(args.namespace, '') + assert.strictEqual(args.importer, '') + assert.strictEqual(args.resolveDir, '') + assert.strictEqual(args.path, './stdinRelative.js') + return { path: args.path, namespace: 'worked' } + }) + build.onLoad({ filter: /.*/, namespace: 'worked' }, () => { + return { contents: `export default 123` } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + }, + + async stdinRelativeResolveDir({ esbuild, testDir }) { + const output = path.join(testDir, 'out', 'out.js') + await esbuild.build({ + stdin: { + contents: `import x from "./stdinRelative.js"; export default x`, + resolveDir: testDir, + }, + bundle: true, outfile: output, format: 'cjs', plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + assert.strictEqual(args.namespace, '') + assert.strictEqual(args.importer, '') + assert.strictEqual(args.resolveDir, testDir) + assert.strictEqual(args.path, './stdinRelative.js') + return { path: args.path, namespace: 'worked' } + }) + build.onLoad({ filter: /.*/, namespace: 'worked' }, () => { + return { contents: `export default 123` } + }) + }, + }], + }) + const result = require(output) + assert.strictEqual(result.default, 123) + }, +} + +async function main() { + // Start the esbuild service + const esbuild = installForTests(rootTestDir) + const service = await esbuild.startService() + + // Run all tests concurrently + const runTest = async ([name, fn]) => { + let testDir = path.join(rootTestDir, name) + try { + await mkdirAsync(testDir) + await fn({ esbuild, service, testDir }) + rimraf.sync(testDir, { disableGlob: true }) + return true + } catch (e) { + console.error(`❌ ${name}: ${e && e.message || e}`) + return false + } + } + const tests = Object.entries(pluginTests) + const allTestsPassed = (await Promise.all(tests.map(runTest))).every(success => success) + + // Clean up test output + service.stop() + + if (!allTestsPassed) { + console.error(`❌ plugin tests failed`) + process.exit(1) + } else { + console.log(`✅ plugin tests passed`) + rimraf.sync(rootTestDir, { disableGlob: true }) + } +} + +main().catch(e => setTimeout(() => { throw e })) diff --git a/scripts/ts-type-tests.js b/scripts/ts-type-tests.js new file mode 100644 index 00000000000..34fc6e437a8 --- /dev/null +++ b/scripts/ts-type-tests.js @@ -0,0 +1,259 @@ +const child_process = require('child_process') +const rimraf = require('rimraf') +const path = require('path') +const fs = require('fs') + +const tsconfigJson = { + compilerOptions: { + module: 'CommonJS', + strict: true, + }, +} + +const tests = { + emptyBuildRequire: ` + export {} + import esbuild = require('esbuild') + esbuild.buildSync({}) + esbuild.build({}) + esbuild.startService().then(service => service.build({})) + `, + emptyBuildImport: ` + import * as esbuild from 'esbuild' + esbuild.buildSync({}) + esbuild.build({}) + esbuild.startService().then(service => service.build({})) + `, + emptyTransformRequire: ` + export {} + import esbuild = require('esbuild') + esbuild.transformSync('') + esbuild.transformSync('', {}) + esbuild.transform('') + esbuild.transform('', {}) + esbuild.startService().then(service => service.transform('')) + esbuild.startService().then(service => service.transform('', {})) + `, + emptyTransformImport: ` + import * as esbuild from 'esbuild' + esbuild.transformSync('') + esbuild.transformSync('', {}) + esbuild.transform('') + esbuild.transform('', {}) + esbuild.startService().then(service => service.transform('')) + esbuild.startService().then(service => service.transform('', {})) + `, + allOptionsTransform: ` + export {} + import {transform} from 'esbuild' + transform('', { + sourcemap: true, + format: 'iife', + globalName: '', + target: 'esnext', + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + charset: 'utf8', + jsxFactory: '', + jsxFragment: '', + define: { 'x': 'y' }, + pure: ['x'], + avoidTDZ: true, + color: true, + logLevel: 'info', + errorLimit: 0, + tsconfigRaw: { + compilerOptions: { + jsxFactory: '', + jsxFragmentFactory: '', + useDefineForClassFields: true, + importsNotUsedAsValues: 'preserve', + }, + }, + sourcefile: '', + loader: 'ts', + }).then(result => { + let code: string = result.code; + let map: string = result.map; + for (let msg of result.warnings) { + let text: string = msg.text + if (msg.location !== null) { + let file: string = msg.location.file; + let namespace: string = msg.location.namespace; + let line: number = msg.location.line; + let column: number = msg.location.column; + let length: number = msg.location.length; + let lineText: string = msg.location.lineText; + } + } + }) + `, + allOptionsBuild: ` + export {} + import {build} from 'esbuild' + build({ + sourcemap: true, + format: 'iife', + globalName: '', + target: 'esnext', + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + charset: 'utf8', + jsxFactory: '', + jsxFragment: '', + define: { 'x': 'y' }, + pure: ['x'], + avoidTDZ: true, + color: true, + logLevel: 'info', + errorLimit: 0, + bundle: true, + splitting: true, + outfile: '', + metafile: '', + outdir: '', + outbase: '', + platform: 'node', + external: ['x'], + loader: { 'x': 'ts' }, + resolveExtensions: ['x'], + mainFields: ['x'], + write: true, + tsconfig: 'x', + outExtension: { 'x': 'y' }, + publicPath: 'x', + inject: ['x'], + entryPoints: ['x'], + stdin: { + contents: '', + resolveDir: '', + sourcefile: '', + loader: 'ts', + }, + plugins: [ + { + name: 'x', + setup(build) { + build.onResolve({filter: /./}, () => undefined) + build.onLoad({filter: /./}, () => undefined) + build.onResolve({filter: /./, namespace: ''}, args => { + let path: string = args.path; + let importer: string = args.importer; + let namespace: string = args.namespace; + let resolveDir: string = args.resolveDir; + if (Math.random()) return + if (Math.random()) return {} + return { + pluginName: '', + errors: [ + {}, + {text: ''}, + {text: '', location: {}}, + {location: {file: '', line: 0}}, + {location: {file: '', namespace: '', line: 0, column: 0, length: 0, lineText: ''}}, + ], + warnings: [ + {}, + {text: ''}, + {text: '', location: {}}, + {location: {file: '', line: 0}}, + {location: {file: '', namespace: '', line: 0, column: 0, length: 0, lineText: ''}}, + ], + path: '', + external: true, + namespace: '', + } + }) + build.onLoad({filter: /./, namespace: ''}, args => { + let path: string = args.path; + let namespace: string = args.namespace; + if (Math.random()) return + if (Math.random()) return {} + return { + pluginName: '', + errors: [ + {}, + {text: ''}, + {text: '', location: {}}, + {location: {file: '', line: 0}}, + {location: {file: '', namespace: '', line: 0, column: 0, length: 0, lineText: ''}}, + ], + warnings: [ + {}, + {text: ''}, + {text: '', location: {}}, + {location: {file: '', line: 0}}, + {location: {file: '', namespace: '', line: 0, column: 0, length: 0, lineText: ''}}, + ], + contents: '', + resolveDir: '', + loader: 'ts', + } + }) + }, + } + ], + }).then(result => { + if (result.outputFiles !== undefined) { + for (let file of result.outputFiles) { + let path: string = file.path + let bytes: Uint8Array = file.contents + } + } + for (let msg of result.warnings) { + let text: string = msg.text + if (msg.location !== null) { + let file: string = msg.location.file; + let namespace: string = msg.location.namespace; + let line: number = msg.location.line; + let column: number = msg.location.column; + let length: number = msg.location.length; + let lineText: string = msg.location.lineText; + } + } + }) + `, +} + +async function main() { + let testDir = path.join(__dirname, '.ts-types-test') + rimraf.sync(testDir, { disableGlob: true }) + fs.mkdirSync(testDir, { recursive: true }) + fs.writeFileSync(path.join(testDir, 'tsconfig.json'), JSON.stringify(tsconfigJson)) + + const types = fs.readFileSync(path.join(__dirname, '..', 'lib', 'types.ts'), 'utf8') + const esbuild_d_ts = path.join(testDir, 'node_modules', 'esbuild', 'index.d.ts') + fs.mkdirSync(path.dirname(esbuild_d_ts), { recursive: true }) + fs.writeFileSync(esbuild_d_ts, ` + declare module 'esbuild' { + ${types.replace(/export declare/g, 'export')} + } + `) + + let files = [] + for (const name in tests) { + const input = path.join(testDir, name + '.ts') + fs.writeFileSync(input, tests[name]) + files.push(input) + } + + const tsc = path.join(__dirname, 'node_modules', 'typescript', 'lib', 'tsc.js') + const allTestsPassed = await new Promise(resolve => { + const child = child_process.spawn('node', [tsc, '--project', '.'], { cwd: testDir, stdio: 'inherit' }) + child.on('close', exitCode => resolve(exitCode === 0)) + }) + + if (!allTestsPassed) { + console.error(`❌ typescript type tests failed`) + process.exit(1) + } else { + console.log(`✅ typescript type tests passed`) + rimraf.sync(testDir, { disableGlob: true }) + } +} + +main() diff --git a/version.txt b/version.txt index a3df0a6959e..100435be135 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.8.0 +0.8.2