//go:build !js || !wasm // +build !js !wasm package api import ( "fmt" "net" "net/http" "path" "sort" "strconv" "strings" "sync" "syscall" "time" "github.com/evanw/esbuild/internal/config" "github.com/evanw/esbuild/internal/fs" "github.com/evanw/esbuild/internal/helpers" "github.com/evanw/esbuild/internal/logger" ) //////////////////////////////////////////////////////////////////////////////// // Serve API type apiHandler struct { mutex sync.Mutex outdirPathPrefix string servedir string options *config.Options onRequest func(ServeOnRequestArgs) rebuild func() BuildResult currentBuild *runningBuild fs fs.FS serveWaitGroup sync.WaitGroup serveError error } type runningBuild struct { waitGroup sync.WaitGroup result BuildResult } func (h *apiHandler) build() BuildResult { build := func() *runningBuild { h.mutex.Lock() defer h.mutex.Unlock() if h.currentBuild == nil { build := &runningBuild{} build.waitGroup.Add(1) h.currentBuild = build // Build on another thread go func() { result := h.rebuild() h.rebuild = result.Rebuild build.result = result build.waitGroup.Done() // Build results stay valid for a little bit afterward since a page // load may involve multiple requests and don't want to rebuild // separately for each of those requests. time.Sleep(250 * time.Millisecond) h.mutex.Lock() defer h.mutex.Unlock() h.currentBuild = nil }() } return h.currentBuild }() build.waitGroup.Wait() return build.result } func escapeForHTML(text string) string { text = strings.ReplaceAll(text, "&", "&") text = strings.ReplaceAll(text, "<", "<") text = strings.ReplaceAll(text, ">", ">") return text } func escapeForAttribute(text string) string { text = escapeForHTML(text) text = strings.ReplaceAll(text, "\"", """) text = strings.ReplaceAll(text, "'", "'") return text } func (h *apiHandler) notifyRequest(duration time.Duration, req *http.Request, status int) { if h.onRequest != nil { h.onRequest(ServeOnRequestArgs{ RemoteAddress: req.RemoteAddr, Method: req.Method, Path: req.URL.Path, Status: status, TimeInMS: int(duration.Milliseconds()), }) } } func errorsToString(errors []Message) string { stderrOptions := logger.OutputOptions{IncludeSource: true} terminalOptions := logger.TerminalInfo{} sb := strings.Builder{} limit := 5 for i, msg := range convertMessagesToInternal(nil, logger.Error, errors) { if i == limit { sb.WriteString(fmt.Sprintf("%d out of %d errors shown\n", limit, len(errors))) break } sb.WriteString(msg.String(stderrOptions, terminalOptions)) } return sb.String() } func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) { start := time.Now() // Handle get requests if req.Method == "GET" && strings.HasPrefix(req.URL.Path, "/") { res.Header().Set("Access-Control-Allow-Origin", "*") queryPath := path.Clean(req.URL.Path)[1:] result := h.build() // Requests fail if the build had errors if len(result.Errors) > 0 { go h.notifyRequest(time.Since(start), req, http.StatusServiceUnavailable) res.Header().Set("Content-Type", "text/plain; charset=utf-8") res.WriteHeader(http.StatusServiceUnavailable) res.Write([]byte(errorsToString(result.Errors))) return } var kind fs.EntryKind var fileContents fs.OpenedFile dirEntries := make(map[string]bool) fileEntries := make(map[string]bool) // Check for a match with the results if we're within the output directory if strings.HasPrefix(queryPath, h.outdirPathPrefix) { outdirQueryPath := queryPath[len(h.outdirPathPrefix):] if strings.HasPrefix(outdirQueryPath, "/") { outdirQueryPath = outdirQueryPath[1:] } resultKind, inMemoryBytes := h.matchQueryPathToResult(outdirQueryPath, &result, dirEntries, fileEntries) kind = resultKind fileContents = &fs.InMemoryOpenedFile{Contents: inMemoryBytes} } else { // Create a fake directory entry for the output path so that it appears to be a real directory p := h.outdirPathPrefix for p != "" { var dir string var base string if slash := strings.IndexByte(p, '/'); slash == -1 { base = p } else { dir = p[:slash] base = p[slash+1:] } if dir == queryPath { kind = fs.DirEntry dirEntries[base] = true break } p = dir } } // Check for a file in the fallback directory if h.servedir != "" && kind != fs.FileEntry { absPath := h.fs.Join(h.servedir, queryPath) if absDir := h.fs.Dir(absPath); absDir != absPath { if entries, err, _ := h.fs.ReadDirectory(absDir); err == nil { if entry, _ := entries.Get(h.fs.Base(absPath)); entry != nil && entry.Kind(h.fs) == fs.FileEntry { if contents, err, _ := h.fs.OpenFile(absPath); err == nil { defer contents.Close() fileContents = contents kind = fs.FileEntry } else if err != syscall.ENOENT { go h.notifyRequest(time.Since(start), req, http.StatusInternalServerError) res.WriteHeader(http.StatusInternalServerError) res.Write([]byte(fmt.Sprintf("500 - Internal server error: %s", err.Error()))) return } } } } } // Check for a directory in the fallback directory var fallbackIndexName string if h.servedir != "" && kind != fs.FileEntry { if entries, err, _ := h.fs.ReadDirectory(h.fs.Join(h.servedir, queryPath)); err == nil { kind = fs.DirEntry for _, name := range entries.SortedKeys() { entry, _ := entries.Get(name) switch entry.Kind(h.fs) { case fs.DirEntry: dirEntries[name] = true case fs.FileEntry: fileEntries[name] = true if name == "index.html" { fallbackIndexName = name } } } } else if err != syscall.ENOENT { go h.notifyRequest(time.Since(start), req, http.StatusInternalServerError) res.WriteHeader(http.StatusInternalServerError) res.Write([]byte(fmt.Sprintf("500 - Internal server error: %s", err.Error()))) return } } // Redirect to a trailing slash for directories if kind == fs.DirEntry && !strings.HasSuffix(req.URL.Path, "/") { res.Header().Set("Location", req.URL.Path+"/") go h.notifyRequest(time.Since(start), req, http.StatusFound) res.WriteHeader(http.StatusFound) res.Write(nil) return } // Serve a "index.html" file if present if kind == fs.DirEntry && fallbackIndexName != "" { queryPath += "/" + fallbackIndexName if contents, err, _ := h.fs.OpenFile(h.fs.Join(h.servedir, queryPath)); err == nil { defer contents.Close() fileContents = contents kind = fs.FileEntry } else if err != syscall.ENOENT { go h.notifyRequest(time.Since(start), req, http.StatusInternalServerError) res.WriteHeader(http.StatusInternalServerError) res.Write([]byte(fmt.Sprintf("500 - Internal server error: %s", err.Error()))) return } } // Serve a file if kind == fs.FileEntry { // Default to serving the whole file status := http.StatusOK fileContentsLen := fileContents.Len() begin := 0 end := fileContentsLen isRange := false // Handle range requests so that video playback works in Safari if rangeBegin, rangeEnd, ok := parseRangeHeader(req.Header.Get("Range"), fileContentsLen); ok && rangeBegin < rangeEnd { // Note: The content range is inclusive so subtract 1 from the end isRange = true begin = rangeBegin end = rangeEnd status = http.StatusPartialContent } // Try to read the range from the file, which may fail fileBytes, err := fileContents.Read(begin, end) if err != nil { go h.notifyRequest(time.Since(start), req, http.StatusInternalServerError) res.WriteHeader(http.StatusInternalServerError) res.Write([]byte(fmt.Sprintf("500 - Internal server error: %s", err.Error()))) return } // If we get here, the request was successful if contentType := helpers.MimeTypeByExtension(path.Ext(queryPath)); contentType != "" { res.Header().Set("Content-Type", contentType) } else { res.Header().Set("Content-Type", "application/octet-stream") } if isRange { res.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", begin, end-1, fileContentsLen)) } res.Header().Set("Content-Length", fmt.Sprintf("%d", len(fileBytes))) go h.notifyRequest(time.Since(start), req, status) res.WriteHeader(status) res.Write(fileBytes) return } // Serve a directory listing if kind == fs.DirEntry { html := respondWithDirList(queryPath, dirEntries, fileEntries) res.Header().Set("Content-Type", "text/html; charset=utf-8") res.Header().Set("Content-Length", fmt.Sprintf("%d", len(html))) go h.notifyRequest(time.Since(start), req, http.StatusOK) res.Write(html) return } } // Default to a 404 res.Header().Set("Content-Type", "text/plain; charset=utf-8") go h.notifyRequest(time.Since(start), req, http.StatusNotFound) res.WriteHeader(http.StatusNotFound) res.Write([]byte("404 - Not Found")) } // Handle enough of the range specification so that video playback works in Safari func parseRangeHeader(r string, contentLength int) (int, int, bool) { if strings.HasPrefix(r, "bytes=") { r = r[len("bytes="):] if dash := strings.IndexByte(r, '-'); dash != -1 { // Note: The range is inclusive so the limit is deliberately "length - 1" if begin, ok := parseRangeInt(r[:dash], contentLength-1); ok { if end, ok := parseRangeInt(r[dash+1:], contentLength-1); ok { // Note: The range is inclusive so a range of "0-1" is two bytes long return begin, end + 1, true } } } } return 0, 0, false } func parseRangeInt(text string, maxValue int) (int, bool) { if text == "" { return 0, false } value := 0 for _, c := range text { if c < '0' || c > '9' { return 0, false } value = value*10 + int(c-'0') if value > maxValue { return 0, false } } return value, true } func (h *apiHandler) matchQueryPathToResult( queryPath string, result *BuildResult, dirEntries map[string]bool, fileEntries map[string]bool, ) (fs.EntryKind, []byte) { queryIsDir := false queryDir := queryPath if queryDir != "" { queryDir += "/" } // Check the output files for a match for _, file := range result.OutputFiles { if relPath, ok := h.fs.Rel(h.options.AbsOutputDir, file.Path); ok { relPath = strings.ReplaceAll(relPath, "\\", "/") // An exact match if relPath == queryPath { return fs.FileEntry, file.Contents } // A match inside this directory if strings.HasPrefix(relPath, queryDir) { entry := relPath[len(queryDir):] queryIsDir = true if slash := strings.IndexByte(entry, '/'); slash == -1 { fileEntries[entry] = true } else if dir := entry[:slash]; !dirEntries[dir] { dirEntries[dir] = true } } } } // Treat this as a directory if it's non-empty if queryIsDir { return fs.DirEntry, nil } return 0, nil } func respondWithDirList(queryPath string, dirEntries map[string]bool, fileEntries map[string]bool) []byte { queryPath = "/" + queryPath queryDir := queryPath if queryDir != "/" { queryDir += "/" } html := strings.Builder{} html.WriteString(``) html.WriteString(``) html.WriteString(`Directory: `) html.WriteString(escapeForHTML(queryDir)) html.WriteString(``) html.WriteString(`

Directory: `) html.WriteString(escapeForHTML(queryDir)) html.WriteString(`

`) html.WriteString(``) return []byte(html.String()) } // This is used to make error messages platform-independent func prettyPrintPath(fs fs.FS, path string) string { if relPath, ok := fs.Rel(fs.Cwd(), path); ok { return strings.ReplaceAll(relPath, "\\", "/") } return path } func serveImpl(serveOptions ServeOptions, buildOptions BuildOptions) (ServeResult, error) { realFS, err := fs.RealFS(fs.RealFSOptions{ AbsWorkingDir: buildOptions.AbsWorkingDir, // This is a long-lived file system object so do not cache calls to // ReadDirectory() (they are normally cached for the duration of a build // for performance). DoNotCache: true, }) if err != nil { return ServeResult{}, err } buildOptions.Incremental = true buildOptions.Write = false // Watch and serve are both different ways of rebuilding, and cannot be combined if buildOptions.Watch != nil { return ServeResult{}, fmt.Errorf("Cannot use \"watch\" with \"serve\"") } // Validate the fallback path if serveOptions.Servedir != "" { if absPath, ok := realFS.Abs(serveOptions.Servedir); ok { serveOptions.Servedir = absPath } else { return ServeResult{}, fmt.Errorf("Invalid serve path: %s", serveOptions.Servedir) } } // If there is no output directory, set the output directory to something so // the build doesn't try to write to stdout. Make sure not to set this to a // path that may contain the user's files in it since we don't want to get // errors about overwriting input files. outdirPathPrefix := "" if buildOptions.Outdir == "" && buildOptions.Outfile == "" { buildOptions.Outdir = realFS.Join(realFS.Cwd(), "...") } else if serveOptions.Servedir != "" { // Compute the output directory var outdir string if buildOptions.Outdir != "" { if absPath, ok := realFS.Abs(buildOptions.Outdir); ok { outdir = absPath } else { return ServeResult{}, fmt.Errorf("Invalid outdir path: %s", buildOptions.Outdir) } } else { if absPath, ok := realFS.Abs(buildOptions.Outfile); ok { outdir = realFS.Dir(absPath) } else { return ServeResult{}, fmt.Errorf("Invalid outdir path: %s", buildOptions.Outfile) } } // Make sure the output directory is contained in the fallback directory relPath, ok := realFS.Rel(serveOptions.Servedir, outdir) if !ok { return ServeResult{}, fmt.Errorf( "Cannot compute relative path from %q to %q\n", serveOptions.Servedir, outdir) } relPath = strings.ReplaceAll(relPath, "\\", "/") // Fix paths on Windows if relPath == ".." || strings.HasPrefix(relPath, "../") { return ServeResult{}, fmt.Errorf( "Output directory %q must be contained in serve directory %q", prettyPrintPath(realFS, outdir), prettyPrintPath(realFS, serveOptions.Servedir), ) } if relPath != "." { outdirPathPrefix = relPath } } // Determine the host var listener net.Listener network := "tcp4" host := "0.0.0.0" if serveOptions.Host != "" { host = serveOptions.Host // Only use "tcp4" if this is an IPv4 address, otherwise use "tcp" if ip := net.ParseIP(host); ip == nil || ip.To4() == nil { network = "tcp" } } // Pick the port if serveOptions.Port == 0 { // Default to picking a "800X" port for port := 8000; port <= 8009; port++ { if result, err := net.Listen(network, net.JoinHostPort(host, fmt.Sprintf("%d", port))); err == nil { listener = result break } } } if listener == nil { // Otherwise pick the provided port if result, err := net.Listen(network, net.JoinHostPort(host, fmt.Sprintf("%d", serveOptions.Port))); err != nil { return ServeResult{}, err } else { listener = result } } // Try listening on the provided port addr := listener.Addr().String() // Extract the real port in case we passed a port of "0" var result ServeResult if host, text, err := net.SplitHostPort(addr); err == nil { if port, err := strconv.ParseInt(text, 10, 32); err == nil { result.Port = uint16(port) result.Host = host } } var stoppingMutex sync.Mutex isStopping := false // The first build will just build normally var handler *apiHandler handler = &apiHandler{ onRequest: serveOptions.OnRequest, outdirPathPrefix: outdirPathPrefix, servedir: serveOptions.Servedir, rebuild: func() BuildResult { stoppingMutex.Lock() defer stoppingMutex.Unlock() // Don't start more rebuilds if we were told to stop if isStopping { return BuildResult{} } build := buildImpl(buildOptions) if handler.options == nil { handler.options = &build.options } return build.result }, fs: realFS, } // When wait is called, block until the server's call to "Serve()" returns result.Wait = func() error { handler.serveWaitGroup.Wait() return handler.serveError } // Create the server server := &http.Server{Addr: addr, Handler: handler} // When stop is called, block further rebuilds and then close the server result.Stop = func() { stoppingMutex.Lock() defer stoppingMutex.Unlock() // Only try to close the server once if isStopping { return } isStopping = true // Close the server and wait for it to close server.Close() handler.serveWaitGroup.Wait() } // Start the server and signal on "serveWaitGroup" when it stops handler.serveWaitGroup.Add(1) go func() { if err := server.Serve(listener); err != http.ErrServerClosed { handler.serveError = err } handler.serveWaitGroup.Done() }() // Start the first build shortly after this function returns (but not // immediately so that stuff we print right after this will come first) go func() { time.Sleep(10 * time.Millisecond) handler.build() }() return result, nil }