diff --git a/.gitignore b/.gitignore index cef13ebea4b..002b1a4a235 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,12 @@ /npm/esbuild-linux-64/bin/esbuild /npm/esbuild-linux-arm64/bin/esbuild /npm/esbuild-linux-ppc64le/bin/esbuild +/npm/esbuild-wasm/browser.js /npm/esbuild-wasm/esbuild.wasm /npm/esbuild-wasm/lib/ /npm/esbuild-wasm/wasm_exec.js /npm/esbuild-windows-64/esbuild.exe -/pkg/ +/npm/esbuild/lib/ /scripts/.end-to-end-tests/ /scripts/.js-api-tests/ /scripts/.verify-source-map/ diff --git a/CHANGELOG.md b/CHANGELOG.md index abfe1aa78e1..39fe795cf48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## Unreleased + +* Overhaul public-facing API code + + This is a rewrite of all externally facing API code. It fixes some bugs and inconsistencies, adds some new features, and makes it easier to support various use cases going forward. + + At a high-level, esbuild's API supports two separate operations: "build" and "transform". Building means reading from the file system and writing back to the file system. Transforming takes an input string and generates an output string. You should use the build API if you want to take advantage of esbuild's bundling capability, and you should use the transform API if you want to integrate esbuild as a library inside another tool (e.g. a "minify" plugin). This rewrite ensures the APIs for these two operations are exposed consistently for all ways of interacting with esbuild (both through the CLI and as a library). + + Here are some of the highlights: + + * There is now a public Go API ([#152](https://github.com/evanw/esbuild/issues/152)) + + The main API can be found in the [`github.com/evanw/esbuild/pkg/api`](pkg/api/api.go) module. It exposes the exact same features as the JavaScript API. This means you can use esbuild as a JavaScript transformation and bundling library from Go code without having to run esbuild as a child process. There is also the [`github.com/evanw/esbuild/pkg/cli`](pkg/cli/cli.go) module which can be used to wrap the esbuild CLI itself. + + * There are now synchronous JavaScript APIs ([#136](https://github.com/evanw/esbuild/issues/136)) + + Sometimes JavaScript source transformations must be synchronous. For example, using esbuild's API to shim `require()` for `.ts` files was previously not possible because esbuild only had an asynchronous transform API. + + This release adds the new `transformSync()` and `buildSync()` synchronous functions to mirror the existing `transform()` and `build()` asynchronous functions. Note that these synchronous calls incur the cost of starting up a new child process each time, so you should only use these instead of `startService()` if you have to (or if you don't care about optimal performance). + + * There is now an experimental browser-based API ([#172](https://github.com/evanw/esbuild/issues/172)) + + The `esbuild-wasm` package now has a file called `browser.js` that exposes a `createService()` API which is similar to the esbuild API available in node. You can either import the `esbuild-wasm` package using a bundler that respects the `browser` field in `package.json` or import the `esbuild-wasm/lib/browser.js` file directly. + + This is what esbuild's browser API looks like: + + ```ts + interface BrowserOptions { + wasmURL: string + worker?: boolean + } + + interface BrowserService { + transform(input: string, options: TransformOptions): Promise + stop(): void + } + + declare function createService(options: BrowserOptions): Promise + ``` + + You must provide the URL to the `esbuild-wasm/esbuild.wasm` file in `wasmURL`. The optional `worker` parameter can be set to `false` to load the WebAssembly module in the same thread instead of creating a worker thread. Using a worker thread is recommended because it means transforming will not block the main thread. + + This API is experimental and may be changed in the future depending on the feedback it gets. + + * Error messages now use `sourcefile` ([#131](https://github.com/evanw/esbuild/issues/131)) + + Errors from transform API calls now use `sourcefile` as the the original file name if present. Previously the file name in error messages was always `/input.js`. + ## 0.4.14 * Do not reorder `"use strict"` after support code ([#173](https://github.com/evanw/esbuild/issues/173)) diff --git a/Makefile b/Makefile index 954f3022f29..51bc3646b18 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ ESBUILD_VERSION = $(shell cat version.txt) -esbuild: cmd/esbuild/*.go internal/*/*.go +esbuild: cmd/esbuild/*.go pkg/*/*.go internal/*/*.go go build ./cmd/esbuild # These tests are for development @@ -59,15 +59,16 @@ platform-linux-ppc64le: cd npm/esbuild-linux-ppc64le && npm version "$(ESBUILD_VERSION)" --allow-same-version GOOS=linux GOARCH=ppc64le go build -o npm/esbuild-linux-ppc64le/bin/esbuild ./cmd/esbuild -platform-wasm: +platform-wasm: | esbuild GOOS=js GOARCH=wasm go build -o npm/esbuild-wasm/esbuild.wasm ./cmd/esbuild cd npm/esbuild-wasm && npm version "$(ESBUILD_VERSION)" --allow-same-version cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" npm/esbuild-wasm/wasm_exec.js - rm -fr npm/esbuild-wasm/lib && cp -r npm/esbuild/lib npm/esbuild-wasm/lib - cat npm/esbuild/lib/main.js | sed 's/WASM = false/WASM = true/' > npm/esbuild-wasm/lib/main.js + mkdir -p npm/esbuild-wasm/lib + node scripts/esbuild.js ./esbuild --wasm -platform-neutral: +platform-neutral: | esbuild cd npm/esbuild && npm version "$(ESBUILD_VERSION)" --allow-same-version + node scripts/esbuild.js ./esbuild publish-all: update-version-go test-all make -j7 publish-windows publish-darwin publish-linux publish-linux-arm64 publish-linux-ppc64le publish-wasm publish-neutral @@ -104,6 +105,7 @@ clean: rm -rf npm/esbuild-linux-arm64/bin rm -rf npm/esbuild-linux-ppc64le/bin rm -f npm/esbuild-wasm/esbuild.wasm npm/esbuild-wasm/wasm_exec.js + rm -rf npm/esbuild/lib rm -rf npm/esbuild-wasm/lib go clean -testcache ./internal/... diff --git a/README.md b/README.md index d0f34f50394..48728287bf2 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,6 @@ Usage: esbuild [options] [entry points] Options: - --name=... The name of the module --bundle Bundle all dependencies into the output files --outfile=... The output file (for one entry point) --outdir=... The output directory (for multiple entry points) @@ -225,6 +224,7 @@ Options: --external:M Exclude module M from the bundle --format=... Output format (iife, cjs, esm) --color=... Force use of color terminal escapes (true or false) + --global-name=... The name of the global for the IIFE format --minify Sets all --minify-* flags --minify-whitespace Remove whitespace @@ -243,7 +243,7 @@ Advanced options: --sourcemap=external Do not link to the source map with a comment --sourcefile=... Set the source file for the source map (for stdin) --error-limit=... Maximum error count or 0 to disable (default 10) - --log-level=... Disable logging (info, warning, error) + --log-level=... Disable logging (info, warning, error, silent) --resolve-extensions=... A comma-separated list of implicit extensions --metafile=... Write metadata about the build to a JSON file @@ -278,7 +278,6 @@ Example build script: const { build } = require('esbuild') build({ - stdio: 'inherit', entryPoints: ['./src/main.ts'], outfile: './dist/main.js', minify: true, @@ -312,12 +311,16 @@ Example usage: const esbuild = require('esbuild') const service = await esbuild.startService() - // This can be called many times without the overhead of starting a service - const { js } = await service.transform(jsx, { loader: 'jsx' }) - console.log(js) + try { + // This can be called many times without the overhead of starting a service + const { js } = await service.transform(jsx, { loader: 'jsx' }) + console.log(js) + } - // The child process can be explicitly killed when it's no longer needed - service.stop() + finally { + // The child process can be explicitly killed when it's no longer needed + service.stop() + } })() ``` diff --git a/cmd/esbuild/main.go b/cmd/esbuild/main.go index acb2a08b950..8090e3fd0f4 100644 --- a/cmd/esbuild/main.go +++ b/cmd/esbuild/main.go @@ -2,23 +2,15 @@ package main import ( "fmt" - "io/ioutil" "os" "runtime/debug" "runtime/pprof" "runtime/trace" - "strconv" "strings" "time" - "github.com/evanw/esbuild/internal/ast" - "github.com/evanw/esbuild/internal/bundler" - "github.com/evanw/esbuild/internal/fs" - "github.com/evanw/esbuild/internal/lexer" "github.com/evanw/esbuild/internal/logging" - "github.com/evanw/esbuild/internal/parser" - "github.com/evanw/esbuild/internal/printer" - "github.com/evanw/esbuild/internal/resolver" + "github.com/evanw/esbuild/pkg/cli" ) const helpText = ` @@ -26,7 +18,6 @@ Usage: esbuild [options] [entry points] Options: - --name=... The name of the module --bundle Bundle all dependencies into the output files --outfile=... The output file (for one entry point) --outdir=... The output directory (for multiple entry points) @@ -36,6 +27,7 @@ Options: --external:M Exclude module M from the bundle --format=... Output format (iife, cjs, esm) --color=... Force use of color terminal escapes (true or false) + --global-name=... The name of the global for the IIFE format --minify Sets all --minify-* flags --minify-whitespace Remove whitespace @@ -54,7 +46,7 @@ Advanced options: --sourcemap=external Do not link to the source map with a comment --sourcefile=... Set the source file for the source map (for stdin) --error-limit=... Maximum error count or 0 to disable (default 10) - --log-level=... Disable logging (info, warning, error) + --log-level=... Disable logging (info, warning, error, silent) --resolve-extensions=... A comma-separated list of implicit extensions --metafile=... Write metadata about the build to a JSON file @@ -73,511 +65,72 @@ Examples: # Provide input via stdin, get output via stdout esbuild --minify --loader=ts < input.ts > output.js - ` -type argsObject struct { - traceFile string - cpuprofileFile string - rawDefines map[string]parser.DefineFunc - parseOptions parser.ParseOptions - bundleOptions bundler.BundleOptions - resolveOptions resolver.ResolveOptions - logOptions logging.StderrOptions - entryPaths []string -} - -func (args argsObject) logInfo(text string) { - if args.logOptions.LogLevel <= logging.LevelInfo { - fmt.Fprintf(os.Stderr, "%s\n", text) - } -} - -func exitWithError(text string) { - colorRed := "" - colorBold := "" - colorReset := "" - - if logging.GetTerminalInfo(os.Stderr).UseColorEscapes { - colorRed = "\033[1;31m" - colorBold = "\033[0;1m" - colorReset = "\033[0m" - } - - fmt.Fprintf(os.Stderr, "%serror: %s%s%s\n", colorRed, colorBold, text, colorReset) - os.Exit(1) -} - -func (args *argsObject) parseDefine(key string, value string) bool { - // The key must be a dot-separated identifier list - for _, part := range strings.Split(key, ".") { - if !lexer.IsIdentifier(part) { - return false - } - } - - // Lazily create the defines map - if args.rawDefines == nil { - args.rawDefines = make(map[string]parser.DefineFunc) - } - - // Allow substituting for an identifier - if lexer.IsIdentifier(value) { - if _, ok := lexer.Keywords()[value]; !ok { - args.rawDefines[key] = func(findSymbol parser.FindSymbol) ast.E { - return &ast.EIdentifier{findSymbol(value)} - } - return true - } - } - - // Parse the value as JSON - log, done := logging.NewDeferLog() - source := logging.Source{Contents: value} - expr, ok := parser.ParseJSON(log, source, parser.ParseJSONOptions{}) - done() - if !ok { - return false - } - - // Only allow atoms for now - var fn parser.DefineFunc - switch e := expr.Data.(type) { - case *ast.ENull: - fn = func(parser.FindSymbol) ast.E { return &ast.ENull{} } - case *ast.EBoolean: - fn = func(parser.FindSymbol) ast.E { return &ast.EBoolean{e.Value} } - case *ast.EString: - fn = func(parser.FindSymbol) ast.E { return &ast.EString{e.Value} } - case *ast.ENumber: - fn = func(parser.FindSymbol) ast.E { return &ast.ENumber{e.Value} } - default: - return false - } - - args.rawDefines[key] = fn - return true -} - -func (args *argsObject) parseLoader(text string) bundler.Loader { - switch text { - case "js": - return bundler.LoaderJS - case "jsx": - return bundler.LoaderJSX - case "ts": - return bundler.LoaderTS - case "tsx": - return bundler.LoaderTSX - case "json": - return bundler.LoaderJSON - case "text": - return bundler.LoaderText - case "base64": - return bundler.LoaderBase64 - case "dataurl": - return bundler.LoaderDataURL - case "file": - return bundler.LoaderFile - default: - return bundler.LoaderNone - } -} - -func (args *argsObject) parseMemberExpression(text string) ([]string, bool) { - parts := strings.Split(text, ".") - - for _, part := range parts { - if !lexer.IsIdentifier(part) { - return parts, false - } - } - - return parts, true -} - -func parseArgs(fs fs.FS, rawArgs []string) (argsObject, error) { - args := argsObject{ - bundleOptions: bundler.BundleOptions{ - ExtensionToLoader: bundler.DefaultExtensionToLoaderMap(), - }, - resolveOptions: resolver.ResolveOptions{ - ExtensionOrder: []string{".tsx", ".ts", ".jsx", ".mjs", ".cjs", ".js", ".json"}, - ExternalModules: make(map[string]bool), - }, - logOptions: logging.StderrOptions{ - IncludeSource: true, - ErrorLimit: 10, - ExitWhenLimitIsHit: true, - }, - } - - for _, arg := range rawArgs { - switch { - case arg == "--bundle": - args.parseOptions.IsBundling = true - args.bundleOptions.IsBundling = true - - case arg == "--minify": - args.parseOptions.MangleSyntax = true - args.bundleOptions.MangleSyntax = true - args.bundleOptions.RemoveWhitespace = true - args.bundleOptions.MinifyIdentifiers = true - - case arg == "--minify-syntax": - args.parseOptions.MangleSyntax = true - args.bundleOptions.MangleSyntax = true - - case arg == "--minify-whitespace": - args.bundleOptions.RemoveWhitespace = true - - case arg == "--minify-identifiers": - args.bundleOptions.MinifyIdentifiers = true - - case arg == "--sourcemap": - args.bundleOptions.SourceMap = bundler.SourceMapLinkedWithComment - - case arg == "--sourcemap=external": - args.bundleOptions.SourceMap = bundler.SourceMapExternalWithoutComment - - case arg == "--sourcemap=inline": - args.bundleOptions.SourceMap = bundler.SourceMapInline - - case strings.HasPrefix(arg, "--sourcefile="): - args.bundleOptions.SourceFile = arg[len("--sourcefile="):] - - case strings.HasPrefix(arg, "--resolve-extensions="): - extensions := strings.Split(arg[len("--resolve-extensions="):], ",") - for _, ext := range extensions { - if !strings.HasPrefix(ext, ".") { - return argsObject{}, fmt.Errorf("Invalid extension: %q", ext) - } - } - args.resolveOptions.ExtensionOrder = extensions - - case strings.HasPrefix(arg, "--error-limit="): - value, err := strconv.Atoi(arg[len("--error-limit="):]) - if err != nil { - return argsObject{}, fmt.Errorf("Invalid error limit: %s", arg) - } - args.logOptions.ErrorLimit = value - - case strings.HasPrefix(arg, "--name="): - value := arg[len("--name="):] - if !lexer.IsIdentifier(value) { - return argsObject{}, fmt.Errorf("Invalid name: %s", arg) - } - args.bundleOptions.ModuleName = value - - case strings.HasPrefix(arg, "--metafile="): - value := arg[len("--metafile="):] - file, ok := fs.Abs(value) - if !ok { - return argsObject{}, fmt.Errorf("Invalid metadata file: %s", arg) - } - args.bundleOptions.AbsMetadataFile = file - - case strings.HasPrefix(arg, "--outfile="): - value := arg[len("--outfile="):] - file, ok := fs.Abs(value) - if !ok { - return argsObject{}, fmt.Errorf("Invalid output file: %s", arg) - } - args.bundleOptions.AbsOutputFile = file - - case strings.HasPrefix(arg, "--outdir="): - value := arg[len("--outdir="):] - dir, ok := fs.Abs(value) - if !ok { - return argsObject{}, fmt.Errorf("Invalid output directory: %s", arg) - } - args.bundleOptions.AbsOutputDir = dir - - case strings.HasPrefix(arg, "--define:"): - text := arg[len("--define:"):] - equals := strings.IndexByte(text, '=') - if equals == -1 { - return argsObject{}, fmt.Errorf("Missing \"=\": %s", arg) - } - if !args.parseDefine(text[:equals], text[equals+1:]) { - return argsObject{}, fmt.Errorf("Invalid define: %s", arg) - } - - case strings.HasPrefix(arg, "--loader:"): - text := arg[len("--loader:"):] - equals := strings.IndexByte(text, '=') - if equals == -1 { - return argsObject{}, fmt.Errorf("Missing \"=\": %s", arg) - } - extension, loader := text[:equals], text[equals+1:] - if !strings.HasPrefix(extension, ".") { - return argsObject{}, fmt.Errorf("File extension must start with \".\": %s", arg) - } - if len(extension) < 2 || strings.ContainsRune(extension[1:], '.') { - return argsObject{}, fmt.Errorf("Invalid file extension: %s", arg) - } - parsedLoader := args.parseLoader(loader) - if parsedLoader == bundler.LoaderNone { - return argsObject{}, fmt.Errorf("Invalid loader: %s", arg) - } else { - args.bundleOptions.ExtensionToLoader[extension] = parsedLoader - } - - case strings.HasPrefix(arg, "--loader="): - loader := arg[len("--loader="):] - parsedLoader := args.parseLoader(loader) - switch parsedLoader { - // Forbid the "file" loader with stdin - case bundler.LoaderNone, bundler.LoaderFile: - return argsObject{}, fmt.Errorf("Invalid loader: %s", arg) - default: - args.bundleOptions.LoaderForStdin = parsedLoader - } - - case strings.HasPrefix(arg, "--target="): - switch arg[len("--target="):] { - case "esnext": - args.parseOptions.Target = parser.ESNext - case "es6", "es2015": - args.parseOptions.Target = parser.ES2015 - case "es2016": - args.parseOptions.Target = parser.ES2016 - case "es2017": - args.parseOptions.Target = parser.ES2017 - case "es2018": - args.parseOptions.Target = parser.ES2018 - case "es2019": - args.parseOptions.Target = parser.ES2019 - case "es2020": - args.parseOptions.Target = parser.ES2020 - default: - return argsObject{}, fmt.Errorf("Valid targets: es6, es2015, es2016, es2017, es2018, es2019, es2020, esnext") - } - - case strings.HasPrefix(arg, "--platform="): - switch arg[len("--platform="):] { - case "browser": - args.resolveOptions.Platform = resolver.PlatformBrowser - case "node": - args.resolveOptions.Platform = resolver.PlatformNode - default: - return argsObject{}, fmt.Errorf("Valid platforms: browser, node") - } - - case strings.HasPrefix(arg, "--format="): - switch arg[len("--format="):] { - case "iife": - args.bundleOptions.OutputFormat = printer.FormatIIFE - case "cjs": - args.bundleOptions.OutputFormat = printer.FormatCommonJS - case "esm": - args.bundleOptions.OutputFormat = printer.FormatESModule - default: - return argsObject{}, fmt.Errorf("Valid formats: iife, cjs, esm") - } - - case strings.HasPrefix(arg, "--color="): - switch arg[len("--color="):] { - case "false": - args.logOptions.Color = logging.ColorNever - case "true": - args.logOptions.Color = logging.ColorAlways - default: - return argsObject{}, fmt.Errorf("Valid values for color: false, true") - } - - case strings.HasPrefix(arg, "--external:"): - path := arg[len("--external:"):] - if resolver.IsNonModulePath(path) { - return argsObject{}, fmt.Errorf("Invalid module name: %s", arg) - } - args.resolveOptions.ExternalModules[path] = true - - case strings.HasPrefix(arg, "--jsx-factory="): - if parts, ok := args.parseMemberExpression(arg[len("--jsx-factory="):]); ok { - args.parseOptions.JSX.Factory = parts - } else { - return argsObject{}, fmt.Errorf("Invalid JSX factory: %s", arg) - } - - case strings.HasPrefix(arg, "--jsx-fragment="): - if parts, ok := args.parseMemberExpression(arg[len("--jsx-fragment="):]); ok { - args.parseOptions.JSX.Fragment = parts - } else { - return argsObject{}, fmt.Errorf("Invalid JSX fragment: %s", arg) - } - - case strings.HasPrefix(arg, "--log-level="): - switch arg[len("--log-level="):] { - case "info": - args.logOptions.LogLevel = logging.LevelInfo - case "warning": - args.logOptions.LogLevel = logging.LevelWarning - case "error": - args.logOptions.LogLevel = logging.LevelError - default: - return argsObject{}, fmt.Errorf("Invalid log level: %s", arg) - } - - case strings.HasPrefix(arg, "--trace="): - args.traceFile = arg[len("--trace="):] - - case strings.HasPrefix(arg, "--cpuprofile="): - args.cpuprofileFile = arg[len("--cpuprofile="):] - - case strings.HasPrefix(arg, "-"): - return argsObject{}, fmt.Errorf("Invalid flag: %s", arg) - - default: - arg, ok := fs.Abs(arg) - if !ok { - return argsObject{}, fmt.Errorf("Invalid path: %s", arg) - } - args.entryPaths = append(args.entryPaths, arg) - } - } - - if args.bundleOptions.AbsOutputDir == "" && len(args.entryPaths) > 1 { - return argsObject{}, fmt.Errorf("Must provide --outdir when there are multiple input files") - } - - if args.bundleOptions.AbsOutputFile != "" && args.bundleOptions.AbsOutputDir != "" { - return argsObject{}, fmt.Errorf("Cannot use both --outfile and --outdir") - } - - if args.bundleOptions.AbsOutputFile != "" { - // If the output file is specified, use it to derive the output directory - args.bundleOptions.AbsOutputDir = fs.Dir(args.bundleOptions.AbsOutputFile) - } - - // Disallow bundle-only options when not bundling - if !args.bundleOptions.IsBundling { - if args.bundleOptions.OutputFormat != printer.FormatPreserve { - return argsObject{}, fmt.Errorf("Cannot use --format without --bundle") - } - - if len(args.resolveOptions.ExternalModules) > 0 { - return argsObject{}, fmt.Errorf("Cannot use --external without --bundle") - } - } - - if args.bundleOptions.IsBundling && args.bundleOptions.OutputFormat == printer.FormatPreserve { - // If the format isn't specified, set the default format using the platform - switch args.resolveOptions.Platform { - case resolver.PlatformBrowser: - args.bundleOptions.OutputFormat = printer.FormatIIFE - case resolver.PlatformNode: - args.bundleOptions.OutputFormat = printer.FormatCommonJS - } - } - - if len(args.entryPaths) > 0 { - // Disallow the "--loader=" form when not reading from stdin - if args.bundleOptions.LoaderForStdin != bundler.LoaderNone { - return argsObject{}, fmt.Errorf("Must provide file extension for --loader") - } - - // Write to stdout by default if there's only one input file - if len(args.entryPaths) == 1 && args.bundleOptions.AbsOutputFile == "" && args.bundleOptions.AbsOutputDir == "" { - args.bundleOptions.WriteToStdout = true - } - } else if !logging.GetTerminalInfo(os.Stdin).IsTTY { - // If called with no input files and we're not a TTY, read from stdin instead - args.entryPaths = append(args.entryPaths, "") - - // Default to reading JavaScript from stdin - if args.bundleOptions.LoaderForStdin == bundler.LoaderNone { - args.bundleOptions.LoaderForStdin = bundler.LoaderJS - } - - // Write to stdout if no input file is provided - if args.bundleOptions.AbsOutputFile == "" { - if args.bundleOptions.AbsOutputDir != "" { - return argsObject{}, fmt.Errorf("Cannot use --outdir when reading from stdin") - } - args.bundleOptions.WriteToStdout = true - } - } - - // Change the default value for some settings if we're writing to stdout - if args.bundleOptions.WriteToStdout { - if args.bundleOptions.SourceMap != bundler.SourceMapNone { - args.bundleOptions.SourceMap = bundler.SourceMapInline - } - if args.logOptions.LogLevel == logging.LevelNone { - args.logOptions.LogLevel = logging.LevelWarning - } - if args.bundleOptions.AbsMetadataFile != "" { - return argsObject{}, fmt.Errorf("Cannot generate metadata when writing to stdout") - } - - // Forbid the "file" loader since stdout only allows one output file - for _, loader := range args.bundleOptions.ExtensionToLoader { - if loader == bundler.LoaderFile { - return argsObject{}, fmt.Errorf("Cannot use the \"file\" loader when writing to stdout") - } - } - } - - // Processing defines is expensive. Process them once here so the same object - // can be shared between all parsers we create using these arguments. - processedDefines := parser.ProcessDefines(args.rawDefines) - args.parseOptions.Defines = &processedDefines - return args, nil -} - func main() { - for _, arg := range os.Args { + osArgs := os.Args[1:] + traceFile := "" + cpuprofileFile := "" + isRunningService := false + + // Do an initial scan over the argument list + argsEnd := 0 + for _, arg := range osArgs { + switch { // Show help if a common help flag is provided - if arg == "-h" || arg == "-help" || arg == "--help" || arg == "/?" { - fmt.Fprintf(os.Stderr, "%s", helpText) + case arg == "-h", arg == "-help", arg == "--help", arg == "/?": + fmt.Fprintf(os.Stderr, "%s\n", helpText) os.Exit(0) - } // Special-case the version flag here - if arg == "--version" { + case arg == "--version": fmt.Fprintf(os.Stderr, "%s\n", esbuildVersion) os.Exit(0) - } + + case strings.HasPrefix(arg, "--trace="): + traceFile = arg[len("--trace="):] + + case strings.HasPrefix(arg, "--cpuprofile="): + cpuprofileFile = arg[len("--cpuprofile="):] // This flag turns the process into a long-running service that uses // message passing with the host process over stdin/stdout - if arg == "--service" { - runService() - return + case arg == "--service": + isRunningService = true + + default: + // Strip any arguments that were handled above + osArgs[argsEnd] = arg + argsEnd++ } } + osArgs = osArgs[:argsEnd] - start := time.Now() - fs := fs.RealFS() - args, err := parseArgs(fs, os.Args[1:]) - if err != nil { - exitWithError(err.Error()) + // Run in service mode if requested + if isRunningService { + runService() + return } - // Handle when there are no input files (including implicit stdin) - if len(args.entryPaths) == 0 { - if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "%s", helpText) - os.Exit(0) - } else { - exitWithError("No input files") - } + // Print help text when there are no arguments + if len(osArgs) == 0 && logging.GetTerminalInfo(os.Stdin).IsTTY { + fmt.Fprintf(os.Stderr, "%s\n", helpText) + os.Exit(0) } // Capture the defer statements below so the "done" message comes last + exitCode := 1 func() { // To view a CPU trace, use "go tool trace [file]". Note that the trace // viewer doesn't work under Windows Subsystem for Linux for some reason. - if args.traceFile != "" { - f, err := os.Create(args.traceFile) + if traceFile != "" { + f, err := os.Create(traceFile) if err != nil { - exitWithError(fmt.Sprintf("Failed to create a file called '%s': %s", args.traceFile, err.Error())) + logging.PrintErrorToStderr(osArgs, fmt.Sprintf( + "Failed to create trace file: %s", err.Error())) + return } - defer func() { - f.Close() - args.logInfo(fmt.Sprintf("Wrote to %s", args.traceFile)) - }() + defer f.Close() trace.Start(f) defer trace.Stop() } @@ -586,27 +139,26 @@ func main() { // Note: Running the CPU profiler doesn't work under Windows subsystem for // Linux. The profiler has to be built for native Windows and run using the // command prompt instead. - if args.cpuprofileFile != "" { - f, err := os.Create(args.cpuprofileFile) + if cpuprofileFile != "" { + f, err := os.Create(cpuprofileFile) if err != nil { - exitWithError(fmt.Sprintf("Failed to create a file called '%s': %s", args.cpuprofileFile, err.Error())) + logging.PrintErrorToStderr(osArgs, fmt.Sprintf( + "Failed to create cpuprofile file: %s", err.Error())) + return } - defer func() { - f.Close() - args.logInfo(fmt.Sprintf("Wrote to %s", args.cpuprofileFile)) - }() + defer f.Close() pprof.StartCPUProfile(f) defer pprof.StopCPUProfile() } - if args.cpuprofileFile != "" { + if cpuprofileFile != "" { // The CPU profiler in Go only runs at 100 Hz, which is far too slow to // return useful information for esbuild, since it's so fast. Let's keep // running for 30 seconds straight, which should give us 3,000 samples. seconds := 30.0 - args.logInfo(fmt.Sprintf("Running for %g seconds straight due to --cpuprofile...", seconds)) + start := time.Now() for time.Since(start).Seconds() < seconds { - run(fs, args) + exitCode = cli.Run(osArgs) } } else { // Disable the GC since we're just going to allocate a bunch of memory @@ -615,69 +167,9 @@ func main() { // process though. debug.SetGCPercent(-1) - run(fs, args) + exitCode = cli.Run(osArgs) } }() - args.logInfo(fmt.Sprintf("Done in %dms", time.Since(start).Nanoseconds()/1000000)) -} - -func run(fs fs.FS, args argsObject) { - // Parse all files in the bundle - log, join := logging.NewStderrLog(args.logOptions) - resolver := resolver.NewResolver(fs, log, args.resolveOptions) - bundle := bundler.ScanBundle(log, fs, resolver, args.entryPaths, args.parseOptions, args.bundleOptions) - - // Stop now if there were errors - if join().Errors != 0 { - os.Exit(1) - } - - // Generate the results - log2, join2 := logging.NewStderrLog(args.logOptions) - result := bundle.Compile(log2, args.bundleOptions) - - // Stop now if there were errors - if join2().Errors != 0 { - os.Exit(1) - } - - // Create the output directory - if args.bundleOptions.AbsOutputDir != "" { - if err := os.MkdirAll(args.bundleOptions.AbsOutputDir, 0755); err != nil { - exitWithError(fmt.Sprintf("Cannot create output directory: %s", err)) - } - } - - // Write out the results - for _, item := range result { - // Special-case writing to stdout - if args.bundleOptions.WriteToStdout { - _, err := os.Stdout.Write(item.Contents) - if err != nil { - exitWithError(fmt.Sprintf("Failed to write to stdout: %s", err.Error())) - } - continue - } - - // Write out the file - err := ioutil.WriteFile(item.AbsPath, []byte(item.Contents), 0644) - path := resolver.PrettyPath(item.AbsPath) - if err != nil { - exitWithError(fmt.Sprintf("Failed to write to %s (%s)", path, err.Error())) - } - args.logInfo(fmt.Sprintf("Wrote to %s (%s)", path, toSize(len(item.Contents)))) - } -} - -func toSize(bytes int) string { - if bytes < 1024 { - return fmt.Sprintf("%d bytes", bytes) - } - - if bytes < 1024*1024 { - return fmt.Sprintf("%.1fkb", float32(bytes)/float32(1024)) - } - - return fmt.Sprintf("%.1fmb", float32(bytes)/float32(1024*1024)) + os.Exit(exitCode) } diff --git a/cmd/esbuild/service.go b/cmd/esbuild/service.go index 5f67e2242a7..0536699dc20 100644 --- a/cmd/esbuild/service.go +++ b/cmd/esbuild/service.go @@ -9,14 +9,15 @@ import ( "encoding/binary" "fmt" "io" + "io/ioutil" "os" + "path/filepath" "runtime/debug" + "sync" - "github.com/evanw/esbuild/internal/bundler" - "github.com/evanw/esbuild/internal/fs" - "github.com/evanw/esbuild/internal/logging" "github.com/evanw/esbuild/internal/printer" - "github.com/evanw/esbuild/internal/resolver" + "github.com/evanw/esbuild/pkg/api" + "github.com/evanw/esbuild/pkg/cli" ) type responseType = map[string][]byte @@ -42,8 +43,9 @@ func runService() { stream := []byte{} // Write responses on a single goroutine so they aren't interleaved + waitGroup := &sync.WaitGroup{} responses := make(chan responseType) - go writeResponses(responses) + go writeResponses(responses, waitGroup) for { // Read more data from stdin @@ -66,48 +68,62 @@ func runService() { bytes = afterRequest // Clone the input and run it on another goroutine + waitGroup.Add(1) clone := append([]byte{}, request...) - go handleRequest(clone, responses) + go handleRequest(clone, responses, waitGroup) } // Move the remaining partial request to the end to avoid reallocating stream = append(stream[:0], bytes...) } + + // Wait for the last response to be written to stdout + waitGroup.Wait() } -func writeUint32(value uint32) { - bytes := []byte{0, 0, 0, 0} - binary.LittleEndian.PutUint32(bytes, value) - os.Stdout.Write(bytes) +func writeUint32(bytes []byte, value uint32) []byte { + bytes = append(bytes, 0, 0, 0, 0) + binary.LittleEndian.PutUint32(bytes[len(bytes)-4:], value) + return bytes } -func writeResponses(responses chan responseType) { +func writeResponses(responses chan responseType, waitGroup *sync.WaitGroup) { for { - response := <-responses + response, ok := <-responses + if !ok { + break // No more responses + } // Each response is length-prefixed length := 4 for k, v := range response { length += 4 + len(k) + 4 + len(v) } - writeUint32(uint32(length)) + bytes := make([]byte, 0, 4+length) + bytes = writeUint32(bytes, uint32(length)) // Each response is formatted as a series of key/value pairs - writeUint32(uint32(len(response))) + bytes = writeUint32(bytes, uint32(len(response))) for k, v := range response { - writeUint32(uint32(len(k))) - os.Stdout.Write([]byte(k)) - writeUint32(uint32(len(v))) - os.Stdout.Write(v) + bytes = writeUint32(bytes, uint32(len(k))) + bytes = append(bytes, k...) + bytes = writeUint32(bytes, uint32(len(v))) + bytes = append(bytes, v...) } + os.Stdout.Write(bytes) + + // Only signal that this request is done when it has actually been written + waitGroup.Done() } } -func handleRequest(bytes []byte, responses chan responseType) { +func handleRequest(bytes []byte, responses chan responseType, waitGroup *sync.WaitGroup) { // Read the argument count argCount, bytes, ok := readUint32(bytes) if !ok { - return // Invalid request + // Invalid request + waitGroup.Done() + return } // Read the arguments @@ -115,13 +131,17 @@ func handleRequest(bytes []byte, responses chan responseType) { for i := uint32(0); i < argCount; i++ { slice, afterSlice, ok := readLengthPrefixedSlice(bytes) if !ok { - return // Invalid request + // Invalid request + waitGroup.Done() + return } rawArgs = append(rawArgs, string(slice)) bytes = afterSlice } if len(rawArgs) < 2 { - return // Invalid request + // Invalid request + waitGroup.Done() + return } // Requests have the format "id command [args...]" @@ -145,6 +165,9 @@ func handleRequest(bytes []byte, responses chan responseType) { case "build": handleBuildRequest(responses, id, rawArgs) + case "transform": + handleTransformRequest(responses, id, rawArgs) + default: responses <- responseType{ "id": []byte(id), @@ -160,17 +183,7 @@ func handlePingRequest(responses chan responseType, id string, rawArgs []string) } func handleBuildRequest(responses chan responseType, id string, rawArgs []string) { - files, rawArgs := stripFilesFromBuildArgs(rawArgs) - if files == nil { - responses <- responseType{ - "id": []byte(id), - "error": []byte("Invalid build request"), - } - return - } - - mockFS := fs.MockFS(nil) - args, err := parseArgs(mockFS, rawArgs) + options, err := cli.ParseBuildOptions(rawArgs) if err != nil { responses <- responseType{ "id": []byte(id), @@ -179,107 +192,79 @@ func handleBuildRequest(responses chan responseType, id string, rawArgs []string return } - // Make sure we don't accidentally try to read from stdin here - if args.bundleOptions.LoaderForStdin != bundler.LoaderNone { - responses <- responseType{ - "id": []byte(id), - "error": []byte("Cannot read from stdin in service mode"), + result := api.Build(options) + for _, outputFile := range result.OutputFiles { + if err := os.MkdirAll(filepath.Dir(outputFile.Path), 0755); err != nil { + result.Errors = append(result.Errors, api.Message{Text: fmt.Sprintf( + "Failed to create output directory: %s", err.Error())}) + } else if err := ioutil.WriteFile(outputFile.Path, outputFile.Contents, 0644); err != nil { + result.Errors = append(result.Errors, api.Message{Text: fmt.Sprintf( + "Failed to write to output file: %s", err.Error())}) } - return } - // Make sure we don't accidentally try to write to stdout here - if args.bundleOptions.WriteToStdout { + responses <- responseType{ + "id": []byte(id), + "errors": messagesToJSON(result.Errors), + "warnings": messagesToJSON(result.Warnings), + } +} + +func handleTransformRequest(responses chan responseType, id string, rawArgs []string) { + if len(rawArgs) == 0 { responses <- responseType{ "id": []byte(id), - "error": []byte("Cannot write to stdout in service mode"), + "error": []byte("Invalid transform request"), } return } - mockFS = fs.MockFS(files) - log, join := logging.NewDeferLog() - resolver := resolver.NewResolver(mockFS, log, args.resolveOptions) - bundle := bundler.ScanBundle(log, mockFS, resolver, args.entryPaths, args.parseOptions, args.bundleOptions) - - // Stop now if there were errors - msgs := join() - errors := messagesOfKind(logging.Error, msgs) - if len(errors) != 0 { + options, err := cli.ParseTransformOptions(rawArgs[1:]) + if err != nil { responses <- responseType{ - "id": []byte(id), - "errors": messagesToJSON(errors), - "warnings": messagesToJSON(messagesOfKind(logging.Warning, msgs)), + "id": []byte(id), + "error": []byte(err.Error()), } return } - // Generate the results - log, join = logging.NewDeferLog() - results := bundle.Compile(log, args.bundleOptions) - - // Return the results - msgs2 := join() - errors = messagesOfKind(logging.Error, msgs2) - response := responseType{ - "id": []byte(id), - "errors": messagesToJSON(errors), - "warnings": messagesToJSON(append( - messagesOfKind(logging.Warning, msgs), - messagesOfKind(logging.Warning, msgs2)...)), - } - for _, result := range results { - response[result.AbsPath] = result.Contents - } - responses <- response -} - -func stripFilesFromBuildArgs(args []string) (map[string]string, []string) { - for i, arg := range args { - if arg == "--" && i%2 == 0 { - files := make(map[string]string) - for j := 0; j < i; j += 2 { - files[args[j]] = args[j+1] - } - return files, args[i+1:] - } - } - return nil, []string{} -} - -func messagesOfKind(kind logging.MsgKind, msgs []logging.Msg) []logging.Msg { - filtered := []logging.Msg{} - for _, msg := range msgs { - if msg.Kind == kind { - filtered = append(filtered, msg) - } + result := api.Transform(rawArgs[0], options) + responses <- responseType{ + "id": []byte(id), + "errors": messagesToJSON(result.Errors), + "warnings": messagesToJSON(result.Warnings), + "js": result.JS, + "jsSourceMap": result.JSSourceMap, } - return filtered } -func messagesToJSON(msgs []logging.Msg) []byte { - bytes := []byte{'['} +func messagesToJSON(msgs []api.Message) []byte { + j := printer.Joiner{} + j.AddString("[") for _, msg := range msgs { - if len(bytes) > 1 { - bytes = append(bytes, ',') + if j.Length() > 1 { + j.AddString(",") } - lineCount := 0 - columnCount := 0 - // Some errors won't have a location - if msg.Source.PrettyPath != "" { - lineCount, columnCount, _ = logging.ComputeLineAndColumn(msg.Source.Contents[0:msg.Start]) - lineCount++ + // Some messages won't have a location + var location api.Location + if msg.Location != nil { + location = *msg.Location + } else { + location.Length = -1 // Signal that there's no location } - bytes = append(bytes, fmt.Sprintf("%s,%s,%d,%d", + j.AddString(fmt.Sprintf("%s,%d,%s,%d,%d,%s", printer.QuoteForJSON(msg.Text), - printer.QuoteForJSON(msg.Source.PrettyPath), - lineCount, - columnCount)...) + location.Length, + printer.QuoteForJSON(location.File), + location.Line, + location.Column, + printer.QuoteForJSON(location.LineText), + )) } - bytes = append(bytes, ']') - return bytes + j.AddString("]") + return j.Done() } diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 1f8b7871942..e71ba60a298 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -4,10 +4,8 @@ import ( "crypto/sha1" "encoding/base64" "fmt" - "io/ioutil" "mime" "net/http" - "os" "path" "sort" "sync" @@ -68,7 +66,6 @@ type Bundle struct { } type parseFlags struct { - isStdin bool isEntryPoint bool isDisabled bool ignoreIfUnused bool @@ -81,7 +78,7 @@ type parseArgs struct { absPath string prettyPath string sourceIndex uint32 - importSource logging.Source + importSource *logging.Source flags parseFlags pathRange ast.Range parseOptions parser.ParseOptions @@ -96,22 +93,24 @@ type parseResult struct { } func parseFile(args parseArgs) { - contents := "" + source := logging.Source{ + Index: args.sourceIndex, + AbsolutePath: args.absPath, + PrettyPath: args.prettyPath, + } // Disabled files are left empty + stdin := args.bundleOptions.Stdin if !args.flags.isDisabled { - if args.flags.isStdin { - bytes, err := ioutil.ReadAll(os.Stdin) - if err != nil { - args.log.AddRangeError(args.importSource, args.pathRange, - fmt.Sprintf("Could not read from stdin: %s", err.Error())) - args.results <- parseResult{} - return + if stdin != nil { + source.Contents = stdin.Contents + source.PrettyPath = "" + if stdin.SourceFile != "" { + source.PrettyPath = stdin.SourceFile } - contents = string(bytes) } else { var ok bool - contents, ok = args.res.Read(args.absPath) + source.Contents, ok = args.res.Read(args.absPath) if !ok { args.log.AddRangeError(args.importSource, args.pathRange, fmt.Sprintf("Could not read from file: %s", args.absPath)) @@ -121,14 +120,6 @@ func parseFile(args parseArgs) { } } - source := logging.Source{ - Index: args.sourceIndex, - IsStdin: args.flags.isStdin, - AbsolutePath: args.absPath, - PrettyPath: args.prettyPath, - Contents: contents, - } - // Get the file extension extension := path.Ext(args.absPath) @@ -136,8 +127,8 @@ func parseFile(args parseArgs) { loader := args.bundleOptions.ExtensionToLoader[extension] // Special-case reading from stdin - if args.bundleOptions.LoaderForStdin != LoaderNone && source.IsStdin { - loader = args.bundleOptions.LoaderForStdin + if stdin != nil { + loader = stdin.Loader } result := parseResult{ @@ -281,16 +272,14 @@ func ScanBundle( maybeParseFile := func( absPath string, prettyPath string, - importSource logging.Source, + importSource *logging.Source, pathRange ast.Range, flags parseFlags, ) uint32 { sourceIndex, ok := visited[absPath] if !ok { sourceIndex = uint32(len(sources)) - if !flags.isStdin { - visited[absPath] = sourceIndex - } + visited[absPath] = sourceIndex sources = append(sources, logging.Source{}) files = append(files, file{}) remaining++ @@ -316,13 +305,9 @@ func ScanBundle( for _, absPath := range entryPaths { flags := parseFlags{ isEntryPoint: true, - isStdin: bundleOptions.LoaderForStdin != LoaderNone, } - prettyPath := absPath - if !flags.isStdin { - prettyPath = res.PrettyPath(absPath) - } - sourceIndex := maybeParseFile(absPath, prettyPath, logging.Source{}, ast.Range{}, flags) + prettyPath := res.PrettyPath(absPath) + sourceIndex := maybeParseFile(absPath, prettyPath, nil, ast.Range{}, flags) entryPoints = append(entryPoints, sourceIndex) } @@ -365,7 +350,7 @@ func ScanBundle( ignoreIfUnused: resolveResult.IgnoreIfUnused, } prettyPath := res.PrettyPath(resolveResult.AbsolutePath) - sourceIndex := maybeParseFile(resolveResult.AbsolutePath, prettyPath, source, pathRange, flags) + sourceIndex := maybeParseFile(resolveResult.AbsolutePath, prettyPath, &source, pathRange, flags) result.file.resolvedImports[pathText] = sourceIndex // Generate metadata about each import @@ -381,7 +366,7 @@ func ScanBundle( } case resolver.ResolveMissing: - log.AddRangeError(source, pathRange, fmt.Sprintf("Could not resolve %q", pathText)) + log.AddRangeError(&source, pathRange, fmt.Sprintf("Could not resolve %q", pathText)) } } } @@ -440,6 +425,12 @@ const ( SourceMapExternalWithoutComment ) +type StdinInfo struct { + Loader Loader + Contents string + SourceFile string +} + type BundleOptions struct { // true: imports are scanned and bundled along with the file // false: imports are left alone and the file is passed through as-is @@ -457,12 +448,8 @@ type BundleOptions struct { // If present, metadata about the bundle is written as JSON here AbsMetadataFile string - SourceMap SourceMap - SourceFile string // The "original file path" for the source map - - // If this isn't LoaderNone, all entry point contents are assumed to come - // from stdin and must be loaded with this loader - LoaderForStdin Loader + SourceMap SourceMap + Stdin *StdinInfo // If true, make sure to generate a single file that can be written to stdout WriteToStdout bool diff --git a/internal/bundler/linker.go b/internal/bundler/linker.go index c2a376b6655..f85f3dfdfaa 100644 --- a/internal/bundler/linker.go +++ b/internal/bundler/linker.go @@ -331,7 +331,7 @@ func findReachableFiles(sources []logging.Source, files []file, entryPoints []ui } func (c *linkerContext) addRangeError(source logging.Source, r ast.Range, text string) { - c.log.AddRangeError(source, r, text) + c.log.AddRangeError(&source, r, text) c.hasErrors = true } @@ -775,7 +775,7 @@ func (c *linkerContext) matchImportsWithExportsForFile(sourceIndex uint32) { // Warn about importing from a file that is known to not have any exports if status == importCommonJSWithoutExports { source := c.sources[tracker.sourceIndex] - c.log.AddRangeWarning(source, lexer.RangeOfIdentifier(source, namedImport.AliasLoc), + c.log.AddRangeWarning(&source, lexer.RangeOfIdentifier(source, namedImport.AliasLoc), fmt.Sprintf("Import %q will always be undefined", namedImport.Alias)) } @@ -2251,9 +2251,6 @@ func (c *linkerContext) generateSourceMapForChunk(results []compileResult) []byt j.AddString(",\n \"sources\": [") for i, result := range results { sourceFile := c.sources[result.sourceIndex].PrettyPath - if c.options.SourceFile != "" { - sourceFile = c.options.SourceFile - } if i > 0 { j.AddString(", ") } diff --git a/internal/lexer/lexer.go b/internal/lexer/lexer.go index 8783a983e45..cf3e494d79a 100644 --- a/internal/lexer/lexer.go +++ b/internal/lexer/lexer.go @@ -1086,7 +1086,7 @@ func (lexer *Lexer) Next() { // Handle legacy HTML-style comments if lexer.codePoint == '>' { lexer.step() - lexer.log.AddRangeWarning(lexer.source, lexer.Range(), + lexer.log.AddRangeWarning(&lexer.source, lexer.Range(), "Treating \"-->\" as the start of a legacy HTML single-line comment") singleLineHTMLCloseComment: for { @@ -1235,7 +1235,7 @@ func (lexer *Lexer) Next() { lexer.step() lexer.step() lexer.step() - lexer.log.AddRangeWarning(lexer.source, lexer.Range(), + lexer.log.AddRangeWarning(&lexer.source, lexer.Range(), "Treating \"