//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(`