| // Package devserver provides code shared between Bazel and Blaze. | |
| package devserver | |
| import ( | |
| "bytes" | |
| "fmt" | |
| "net" | |
| "net/http" | |
| "net/url" | |
| "os" | |
| "path/filepath" | |
| "strings" | |
| "time" | |
| "github.com/bazelbuild/rules_typescript/devserver/runfiles" | |
| ) | |
| // Convert Windows paths separators. | |
| var pathReplacer = strings.NewReplacer("\\", "/") | |
| func shouldAllowCors(request *http.Request) bool { | |
| hostname, err := os.Hostname() | |
| if err != nil { | |
| return false | |
| } | |
| referer, err := url.Parse(request.Header.Get("Origin")) | |
| if err != nil { | |
| return false | |
| } | |
| host, _, err := net.SplitHostPort(referer.Host) | |
| // SplitHostPort fails when the parameter doesn't have a port. | |
| if err != nil { | |
| host = referer.Host | |
| } | |
| return host == hostname || host == "localhost" | |
| } | |
| func customNotFoundMiddleware(notFound http.HandlerFunc, passThrough http.HandlerFunc) http.HandlerFunc { | |
| return func(writer http.ResponseWriter, request *http.Request) { | |
| passThrough( | |
| &customNotFoundResponseWriter{ResponseWriter: writer, request: request, notFound: notFound}, | |
| request, | |
| ) | |
| } | |
| } | |
| type customNotFoundResponseWriter struct { | |
| http.ResponseWriter | |
| request *http.Request | |
| notFound http.HandlerFunc | |
| has404 bool | |
| hasWrite bool | |
| } | |
| // Write implements http.ResponseWriter.Write. | |
| func (w *customNotFoundResponseWriter) Write(b []byte) (int, error) { | |
| w.hasWrite = true | |
| if w.has404 { | |
| // We have already written the not found response, so drop this one. | |
| return len(b), nil | |
| } | |
| return w.ResponseWriter.Write(b) | |
| } | |
| // WriteHeader implements http.ResponseWriter.WriteHeader. | |
| func (w *customNotFoundResponseWriter) WriteHeader(code int) { | |
| if code != http.StatusNotFound || w.hasWrite { | |
| // We only intercept not found statuses. We also don't intercept statuses written after the | |
| // first write as these are an error and should be handled by the default ResponseWriter. | |
| w.ResponseWriter.WriteHeader(code) | |
| return | |
| } | |
| // WriteHeader writes out the entire header (including content type) and only the first call | |
| // will succeed. Therefore, if we want the correct content type set, we must set it here. | |
| w.Header().Del("Content-Type") | |
| w.Header().Add("Content-Type", "text/html; charset=utf-8") | |
| w.ResponseWriter.WriteHeader(code) | |
| w.has404 = true | |
| // We have already written the header, so drop any calls to WriteHeader made by the not found | |
| // handler. These additional calls are expected, and if passed through, would cause the base | |
| // ResponseWriter to unnecessarily spam the error log. | |
| w.notFound(&headerSuppressorResponseWriter{w.ResponseWriter}, w.request) | |
| w.hasWrite = true | |
| } | |
| type headerSuppressorResponseWriter struct { | |
| http.ResponseWriter | |
| } | |
| // WriteHeader implements http.ResponseWriter.WriteHeader. | |
| func (w *headerSuppressorResponseWriter) WriteHeader(code int) {} | |
| // CreateFileHandler returns an http handler to locate files on disk | |
| func CreateFileHandler(servingPath, manifest string, pkgs []string, base string) http.HandlerFunc { | |
| // We want to add the root runfile path because by default developers should be able to request | |
| // runfiles through their absolute manifest path (e.g. "my_workspace_name/src/file.css") | |
| pkgPaths := dirHTTPFileSystem{append(pkgs, "./"), base} | |
| fileHandler := http.FileServer(pkgPaths).ServeHTTP | |
| defaultPage := []byte(fmt.Sprintf(`<!doctype html> | |
| <html> | |
| <head> | |
| <title>ts_devserver (%s)</title> | |
| </head> | |
| <body> | |
| <script src="%s"></script> | |
| </body> | |
| </html> | |
| `, manifest, servingPath)) | |
| // indexHandler serves an index.html if present, or otherwise serves a minimal | |
| // generated index.html with a script tag to include the bundled js source. | |
| indexHandler := func(w http.ResponseWriter, r *http.Request) { | |
| // search through pkgs for the first index.html file found if any exists | |
| for _, pkg := range pkgs { | |
| // File path is not cached, so that a user's edits will be reflected. | |
| userIndexFile, err := runfiles.Runfile(base, pathReplacer.Replace(filepath.Join(pkg, "index.html"))) | |
| // In case the potential user index file couldn't be found in the runfiles, | |
| // continue searching within other packages. | |
| if err != nil { | |
| continue | |
| } | |
| // We can assume that the file is readable if it's listed in the runfiles manifest. | |
| http.ServeFile(w, r, userIndexFile) | |
| return | |
| } | |
| content := bytes.NewReader(defaultPage) | |
| http.ServeContent(w, r, "index.html", time.Now(), content) | |
| } | |
| // Serve a custom index.html so as to override the default directory listing | |
| // from http.FileServer when no index.html file present. | |
| indexOnNotFoundHandler := func(writer http.ResponseWriter, request *http.Request) { | |
| // The browser can't tell the difference between different source checkouts or different devserver | |
| // instances, so it may mistakenly cache static files (including templates) using versions from | |
| // old instances if they haven't been modified more recently. To prevent this, we force no-cache | |
| // on all static files. | |
| writer.Header().Add("Cache-Control", "no-cache, no-store, must-revalidate") | |
| if shouldAllowCors(request) { | |
| writer.Header().Add("Access-Control-Allow-Origin", request.Header.Get("Origin")) | |
| writer.Header().Add("Access-Control-Allow-Credentials", "true") | |
| } | |
| writer.Header().Add("Pragma", "no-cache") | |
| writer.Header().Add("Expires", "0") | |
| // Add gzip headers if serving .gz files. | |
| if strings.HasSuffix(request.URL.EscapedPath(), ".gz") { | |
| writer.Header().Add("Content-Encoding", "gzip") | |
| } | |
| if request.URL.Path == "/" { | |
| indexHandler(writer, request) | |
| return | |
| } | |
| // When a file is not found, serve a 404 code but serve the index.html from above as its body. | |
| // This allows applications to use html5 routing and reload the page at /some/sub/path, but still | |
| // get their web app served. | |
| writer = &customNotFoundResponseWriter{ResponseWriter: writer, request: request, notFound: indexHandler} | |
| fileHandler(writer, request) | |
| } | |
| return indexOnNotFoundHandler | |
| } | |
| // dirHTTPFileSystem implements http.FileSystem by looking in the list of dirs one after each other. | |
| type dirHTTPFileSystem struct { | |
| packageDirs []string | |
| base string | |
| } | |
| func (fs dirHTTPFileSystem) Open(name string) (http.File, error) { | |
| for _, packageName := range fs.packageDirs { | |
| manifestFilePath := filepath.Join(packageName, name) | |
| realFilePath, err := runfiles.Runfile(fs.base, manifestFilePath) | |
| if err != nil { | |
| // In case the runfile could not be found, we also need to check that the requested | |
| // path does not refer to a directory containing an "index.html" file. This can | |
| // happen if Bazel runs without runfile symlinks, where only files can be resolved | |
| // from the manifest. In that case we dirty check if there is a "index.html" file. | |
| realFilePath, err = runfiles.Runfile(fs.base, filepath.Join(manifestFilePath, "index.html")) | |
| // Continue searching if the runfile couldn't be found for the requested file. | |
| if err != nil { | |
| continue | |
| } | |
| } | |
| stat, err := os.Stat(realFilePath) | |
| if err != nil { | |
| // This should actually never happen because runfiles resolved through the runfile helpers | |
| // should always exist. Just in order to properly handle the error, we add this error handling. | |
| return nil, fmt.Errorf("could not read runfile %s", manifestFilePath) | |
| } | |
| // In case the resolved file resolves to a directory. This can only happen if | |
| // Bazel runs with symlinked runfiles (e.g. on MacOS, linux). In that case, we | |
| // just look for a index.html in the directory. | |
| if stat.IsDir() { | |
| realFilePath, err = runfiles.Runfile(fs.base, filepath.Join(manifestFilePath, "index.html")) | |
| // In case the index.html file of the requested directory couldn't be found, | |
| // we just continue searching. | |
| if err != nil { | |
| continue | |
| } | |
| } | |
| // We can assume that the file is present, if it's listed in the runfile manifest. Though, we | |
| // return the error, in case something prevented the read-access. | |
| return os.Open(realFilePath) | |
| } | |
| return nil, os.ErrNotExist | |
| } |