//go:build !js || !wasm // +build !js !wasm package api // This file implements the "Serve()" function in esbuild's public API. It // provides a basic web server that can serve a directory tree over HTTP. When // a directory is visited the "index.html" will be served if present, otherwise // esbuild will automatically generate a directory listing page with links for // each file in the directory. If there is a build configured that generates // output files, those output files are not written to disk but are instead // "overlayed" virtually on top of the real file system. The server responds to // HTTP requests for output files from the build with the latest in-memory // build results. import ( "errors" "fmt" "net" "net/http" "os" "path" "sort" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/evanw/esbuild/internal/fs" "github.com/evanw/esbuild/internal/helpers" "github.com/evanw/esbuild/internal/logger" ) //////////////////////////////////////////////////////////////////////////////// // Serve API type apiHandler struct { onRequest func(ServeOnRequestArgs) rebuild func() BuildResult stop func() fs fs.FS absOutputDir string outdirPathPrefix string publicPath string servedir string keyfileToLower string certfileToLower string serveWaitGroup sync.WaitGroup activeStreams []chan serverSentEvent buildSummary buildSummary mutex sync.Mutex } type serverSentEvent struct { event string data string } 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() // Special-case the esbuild event stream if req.Method == "GET" && req.URL.Path == "/esbuild" && req.Header.Get("Accept") == "text/event-stream" { h.serveEventStream(start, req, res) return } // 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.rebuild() // Requests fail if the build had errors if len(result.Errors) > 0 { res.Header().Set("Content-Type", "text/plain; charset=utf-8") go h.notifyRequest(time.Since(start), req, http.StatusServiceUnavailable) 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, isImplicitIndexHTML := h.matchQueryPathToResult(outdirQueryPath, &result, dirEntries, fileEntries) kind = resultKind fileContents = &fs.InMemoryOpenedFile{Contents: inMemoryBytes} if isImplicitIndexHTML { queryPath = path.Join(queryPath, "index.html") } } 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 h.keyfileToLower != "" || h.certfileToLower != "" { if toLower := strings.ToLower(absPath); toLower == h.keyfileToLower || toLower == h.certfileToLower { // Don't serve the HTTPS key or certificate. This uses a case- // insensitive check because some file systems are case-sensitive. go h.notifyRequest(time.Since(start), req, http.StatusForbidden) res.WriteHeader(http.StatusForbidden) res.Write([]byte("403 - Forbidden")) return } } 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 an "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")) } // This exposes an event stream to clients using server-sent events: // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events func (h *apiHandler) serveEventStream(start time.Time, req *http.Request, res http.ResponseWriter) { if flusher, ok := res.(http.Flusher); ok { if closer, ok := res.(http.CloseNotifier); ok { // Add a new stream to the array of active streams stream := make(chan serverSentEvent) h.mutex.Lock() h.activeStreams = append(h.activeStreams, stream) h.mutex.Unlock() // Start the event stream res.Header().Set("Content-Type", "text/event-stream") res.Header().Set("Connection", "keep-alive") res.Header().Set("Cache-Control", "no-cache") res.Header().Set("Access-Control-Allow-Origin", "*") go h.notifyRequest(time.Since(start), req, http.StatusOK) res.WriteHeader(http.StatusOK) res.Write([]byte("retry: 500\n")) flusher.Flush() // Send incoming messages over the stream streamWasClosed := make(chan struct{}, 1) go func() { for { var msg []byte select { case next, ok := <-stream: if !ok { streamWasClosed <- struct{}{} return } msg = []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", next.event, next.data)) case <-time.After(30 * time.Second): // Send an occasional keep-alive msg = []byte(":\n\n") } if _, err := res.Write(msg); err != nil { return } flusher.Flush() } }() // When the stream is closed (either by them or by us), remove it // from the array and end the response body to clean up resources select { case <-closer.CloseNotify(): case <-streamWasClosed: } h.mutex.Lock() for i := range h.activeStreams { if h.activeStreams[i] == stream { end := len(h.activeStreams) - 1 h.activeStreams[i] = h.activeStreams[end] h.activeStreams = h.activeStreams[:end] break } } h.mutex.Unlock() close(stream) return } } // If we get here, then event streaming isn't possible go h.notifyRequest(time.Since(start), req, http.StatusInternalServerError) res.WriteHeader(http.StatusInternalServerError) res.Write([]byte("500 - Event stream error")) } func (h *apiHandler) broadcastBuildResult(result BuildResult, newSummary buildSummary) { h.mutex.Lock() var added []string var removed []string var updated []string urlForPath := func(absPath string) (string, bool) { if relPath, ok := h.fs.Rel(h.servedir, absPath); ok { publicPath := h.publicPath slash := "/" if publicPath != "" && strings.HasSuffix(h.publicPath, "/") { slash = "" } return fmt.Sprintf("%s%s%s", publicPath, slash, strings.ReplaceAll(relPath, "\\", "/")), true } return "", false } // Diff the old and new states, but only if the build succeeded. We shouldn't // make it appear as if all files were removed when there is a build error. if len(result.Errors) == 0 { oldSummary := h.buildSummary h.buildSummary = newSummary for absPath, newHash := range newSummary { if oldHash, ok := oldSummary[absPath]; !ok { if url, ok := urlForPath(absPath); ok { added = append(added, url) } } else if newHash != oldHash { if url, ok := urlForPath(absPath); ok { updated = append(updated, url) } } } for absPath := range oldSummary { if _, ok := newSummary[absPath]; !ok { if url, ok := urlForPath(absPath); ok { removed = append(removed, url) } } } } // Only notify listeners if there's a change that's worth sending. That way // you can implement a simple "reload on any change" script without having // to do this check in the script. if len(added) > 0 || len(removed) > 0 || len(updated) > 0 { sort.Strings(added) sort.Strings(removed) sort.Strings(updated) // Assemble the diff var sb strings.Builder sb.WriteString("{\"added\":[") for i, path := range added { if i > 0 { sb.WriteRune(',') } sb.Write(helpers.QuoteForJSON(path, false)) } sb.WriteString("],\"removed\":[") for i, path := range removed { if i > 0 { sb.WriteRune(',') } sb.Write(helpers.QuoteForJSON(path, false)) } sb.WriteString("],\"updated\":[") for i, path := range updated { if i > 0 { sb.WriteRune(',') } sb.Write(helpers.QuoteForJSON(path, false)) } sb.WriteString("]}") json := sb.String() // Broadcast the diff to all streams for _, stream := range h.activeStreams { stream <- serverSentEvent{event: "change", data: json} } } h.mutex.Unlock() } // 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, bool) { 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.absOutputDir, file.Path); ok { relPath = strings.ReplaceAll(relPath, "\\", "/") // An exact match if relPath == queryPath { return fs.FileEntry, file.Contents, false } // Serve an "index.html" file if present if dir, base := path.Split(relPath); base == "index.html" && queryDir == dir { return fs.FileEntry, file.Contents, true } // 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, false } return 0, nil, false } 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("\n") html.WriteString("\n") html.WriteString("\n") html.WriteString("