SandpointsGitHook/vendor/github.com/evanw/esbuild/pkg/api/serve_other.go

930 lines
28 KiB
Go

//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, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
return text
}
func escapeForAttribute(text string) string {
text = escapeForHTML(text)
text = strings.ReplaceAll(text, "\"", "&quot;")
text = strings.ReplaceAll(text, "'", "&apos;")
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("<!doctype html>\n")
html.WriteString("<meta charset=\"utf8\">\n")
html.WriteString("<style>\n")
html.WriteString("body { margin: 30px; color: #222; background: #fff; font: 16px/22px sans-serif; }\n")
html.WriteString("a { color: inherit; text-decoration: none; }\n")
html.WriteString("a:hover { text-decoration: underline; }\n")
html.WriteString("a:visited { color: #777; }\n")
html.WriteString("@media (prefers-color-scheme: dark) {\n")
html.WriteString(" body { color: #fff; background: #222; }\n")
html.WriteString(" a:visited { color: #aaa; }\n")
html.WriteString("}\n")
html.WriteString("</style>\n")
html.WriteString("<title>Directory: ")
html.WriteString(escapeForHTML(queryDir))
html.WriteString("</title>\n")
html.WriteString("<h1>Directory: ")
var parts []string
if queryPath == "/" {
parts = []string{""}
} else {
parts = strings.Split(queryPath, "/")
}
for i, part := range parts {
if i+1 < len(parts) {
html.WriteString("<a href=\"")
html.WriteString(escapeForHTML(strings.Join(parts[:i+1], "/")))
html.WriteString("/\">")
}
html.WriteString(escapeForHTML(part))
html.WriteString("/")
if i+1 < len(parts) {
html.WriteString("</a>")
}
}
html.WriteString("</h1>\n")
// Link to the parent directory
if queryPath != "/" {
parentDir := path.Dir(queryPath)
if parentDir != "/" {
parentDir += "/"
}
html.WriteString(fmt.Sprintf("<div>📁 <a href=\"%s\">../</a></div>\n", escapeForAttribute(parentDir)))
}
// Link to child directories
strings := make([]string, 0, len(dirEntries)+len(fileEntries))
for entry := range dirEntries {
strings = append(strings, entry)
}
sort.Strings(strings)
for _, entry := range strings {
html.WriteString(fmt.Sprintf("<div>📁 <a href=\"%s/\">%s/</a></div>\n", escapeForAttribute(path.Join(queryPath, entry)), escapeForHTML(entry)))
}
// Link to files in the directory
strings = strings[:0]
for entry := range fileEntries {
strings = append(strings, entry)
}
sort.Strings(strings)
for _, entry := range strings {
html.WriteString(fmt.Sprintf("<div>📄 <a href=\"%s\">%s</a></div>\n", escapeForAttribute(path.Join(queryPath, entry)), escapeForHTML(entry)))
}
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 (ctx *internalContext) Serve(serveOptions ServeOptions) (ServeResult, error) {
ctx.mutex.Lock()
defer ctx.mutex.Unlock()
// Ignore disposed contexts
if ctx.didDispose {
return ServeResult{}, errors.New("Cannot serve a disposed context")
}
// Don't allow starting serve mode multiple times
if ctx.handler != nil {
return ServeResult{}, errors.New("Serve mode has already been enabled")
}
// Don't allow starting serve mode multiple times
if (serveOptions.Keyfile != "") != (serveOptions.Certfile != "") {
return ServeResult{}, errors.New("Must specify both key and certificate for HTTPS")
}
// Validate the fallback path
if serveOptions.Servedir != "" {
if absPath, ok := ctx.realFS.Abs(serveOptions.Servedir); ok {
serveOptions.Servedir = absPath
} else {
return ServeResult{}, fmt.Errorf("Invalid serve path: %s", serveOptions.Servedir)
}
}
// Stuff related to the output directory only matters if there are entry points
outdirPathPrefix := ""
if len(ctx.args.entryPoints) > 0 {
// Don't allow serving when builds are written to stdout
if ctx.args.options.WriteToStdout {
what := "entry points"
if len(ctx.args.entryPoints) == 1 {
what = "an entry point"
}
return ServeResult{}, fmt.Errorf("Cannot serve %s without an output path", what)
}
// Compute the output path prefix
if serveOptions.Servedir != "" && ctx.args.options.AbsOutputDir != "" {
// Make sure the output directory is contained in the fallback directory
relPath, ok := ctx.realFS.Rel(serveOptions.Servedir, ctx.args.options.AbsOutputDir)
if !ok {
return ServeResult{}, fmt.Errorf(
"Cannot compute relative path from %q to %q\n", serveOptions.Servedir, ctx.args.options.AbsOutputDir)
}
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(ctx.realFS, ctx.args.options.AbsOutputDir),
prettyPrintPath(ctx.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
}
}
// HTTPS-related files should be absolute paths
isHTTPS := serveOptions.Keyfile != "" && serveOptions.Certfile != ""
if isHTTPS {
serveOptions.Keyfile, _ = ctx.realFS.Abs(serveOptions.Keyfile)
serveOptions.Certfile, _ = ctx.realFS.Abs(serveOptions.Certfile)
}
var shouldStop int32
// The first build will just build normally
handler := &apiHandler{
onRequest: serveOptions.OnRequest,
outdirPathPrefix: outdirPathPrefix,
absOutputDir: ctx.args.options.AbsOutputDir,
publicPath: ctx.args.options.PublicPath,
servedir: serveOptions.Servedir,
keyfileToLower: strings.ToLower(serveOptions.Keyfile),
certfileToLower: strings.ToLower(serveOptions.Certfile),
rebuild: func() BuildResult {
if atomic.LoadInt32(&shouldStop) != 0 {
// Don't start more rebuilds if we were told to stop
return BuildResult{}
} else {
return ctx.activeBuildOrRecentBuildOrRebuild()
}
},
fs: ctx.realFS,
}
// Create the server
server := &http.Server{Addr: addr, Handler: handler}
// When stop is called, block further rebuilds and then close the server
handler.stop = func() {
atomic.StoreInt32(&shouldStop, 1)
// Close the server and wait for it to close
server.Close()
// Close all open event streams
handler.mutex.Lock()
for _, stream := range handler.activeStreams {
close(stream)
}
handler.activeStreams = nil
handler.mutex.Unlock()
handler.serveWaitGroup.Wait()
}
// HACK: Go's HTTP API doesn't appear to provide a way to separate argument
// validation errors from eventual network errors. Specifically "ServeTLS"
// blocks for an arbitrarily long time before returning an error. So we
// intercept the first call to "Accept" on the listener and say that the
// serve call succeeded without an error if we get to that point.
hack := &hackListener{Listener: listener}
hack.waitGroup.Add(1)
// Start the server and signal on "serveWaitGroup" when it stops
handler.serveWaitGroup.Add(1)
go func() {
var err error
if isHTTPS {
err = server.ServeTLS(hack, serveOptions.Certfile, serveOptions.Keyfile)
} else {
err = server.Serve(hack)
}
if err != http.ErrServerClosed {
hack.mutex.Lock()
if !hack.done {
hack.done = true
hack.err = err
hack.waitGroup.Done()
}
hack.mutex.Unlock()
}
handler.serveWaitGroup.Done()
}()
// Return an error if the server failed to start accepting connections
hack.waitGroup.Wait()
if hack.err != nil {
return ServeResult{}, hack.err
}
// There appears to be some issue with Linux (but not with macOS) where
// destroying and recreating a server with the same port as the previous
// server had sometimes causes subsequent connections to fail with
// ECONNRESET (shows up in node as "Error: socket hang up").
//
// I think the problem is sort of that Go sets SO_REUSEADDR to 1 for listener
// sockets (specifically in "setDefaultListenerSockopts"). In some ways this
// is good, because it's more convenient for the user if the port is the
// same. However, I believe this sends a TCP RST packet to kill any previous
// connections. That can then be received by clients attempting to connect
// to the new server.
//
// As a hack to work around this problem, we wait for an additional short
// amount of time before returning. I observed this problem even with a 5ms
// timeout but I did not observe this problem with a 10ms timeout. So I'm
// setting this timeout to 50ms to be extra safe.
time.Sleep(50 * time.Millisecond)
// Only set the context handler if the server started successfully
ctx.handler = handler
// Print the URL(s) that the server can be reached at
if ctx.args.logOptions.LogLevel <= logger.LevelInfo {
printURLs(result.Host, result.Port, isHTTPS, ctx.args.logOptions.Color)
}
// Start the first build shortly after this function returns (but not
// immediately so that stuff we print right after this will come first).
//
// This also helps the CLI not do two builds when serve and watch mode
// are enabled together. Watch mode is enabled after serve mode because
// we want the stderr output for watch to come after the stderr output for
// serve, but watch mode will do another build if the current build is
// not a watch mode build.
go func() {
time.Sleep(10 * time.Millisecond)
handler.rebuild()
}()
return result, nil
}
type hackListener struct {
net.Listener
mutex sync.Mutex
waitGroup sync.WaitGroup
err error
done bool
}
func (hack *hackListener) Accept() (net.Conn, error) {
hack.mutex.Lock()
if !hack.done {
hack.done = true
hack.waitGroup.Done()
}
hack.mutex.Unlock()
return hack.Listener.Accept()
}
func printURLs(host string, port uint16, https bool, useColor logger.UseColor) {
logger.PrintTextWithColor(os.Stderr, useColor, func(colors logger.Colors) string {
var hosts []string
sb := strings.Builder{}
sb.WriteString(colors.Reset)
// If this is "0.0.0.0" or "::", list all relevant IP addresses
if ip := net.ParseIP(host); ip != nil && ip.IsUnspecified() {
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, addr := range addrs {
if addr, ok := addr.(*net.IPNet); ok && (addr.IP.To4() != nil) == (ip.To4() != nil) && !addr.IP.IsLinkLocalUnicast() {
hosts = append(hosts, addr.IP.String())
}
}
}
}
// Otherwise, just list the one IP address
if len(hosts) == 0 {
hosts = append(hosts, host)
}
// Determine the host kinds
kinds := make([]string, len(hosts))
maxLen := 0
for i, host := range hosts {
kind := "Network"
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
kind = "Local"
}
kinds[i] = kind
if len(kind) > maxLen {
maxLen = len(kind)
}
}
// Pretty-print the host list
protocol := "http"
if https {
protocol = "https"
}
for i, kind := range kinds {
sb.WriteString(fmt.Sprintf("\n > %s:%s %s%s://%s/%s",
kind, strings.Repeat(" ", maxLen-len(kind)), colors.Underline, protocol,
net.JoinHostPort(hosts[i], fmt.Sprintf("%d", port)), colors.Reset))
}
sb.WriteString("\n\n")
return sb.String()
})
}