package docsite import ( "bytes" "context" "errors" "fmt" "io" "io/fs" "net" "net/http" "os" "path" "path/filepath" "strings" "sync" "time" "github.com/fsnotify/fsnotify" ) // DefaultServeAddr is the loopback address docs:serve listens on. const DefaultServeAddr = "127.0.0.1:8088" const serveDebounce = 200 * time.Millisecond // Serve builds the site into a fresh temporary directory (removed on // return), serves it on addr and rebuilds when the docs, a module or a // src= source directory changes. A failed rebuild prints its problems and // keeps serving the last good build. addr must be a loopback address // unless allowRemote is set (the flag behind docs:serve --allow-remote). // Serve returns when ctx is done. func Serve(ctx context.Context, opts Options, addr string, allowRemote bool, out io.Writer) error { if err := checkServeAddr(addr, allowRemote); err != nil { return err } opts, err := opts.normalize() if err != nil { return err } tmp, err := os.MkdirTemp("", "summer-docs-") if err != nil { return fmt.Errorf("docs:serve: %w", err) } defer os.RemoveAll(tmp) s := &server{opts: opts, tmp: tmp, out: out} if !s.rebuild() { return errors.New("docs:serve: build failed") } // The watches are in place before the serving line is printed, so an // edit made as soon as the server is announced triggers a rebuild. w, err := fsnotify.NewWatcher() if err != nil { return fmt.Errorf("docs:serve: watcher: %w", err) } defer w.Close() s.addWatches(w) ln, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("docs:serve: %w", err) } srv := &http.Server{Handler: dirHandler(s.dir), ReadHeaderTimeout: 10 * time.Second} serveErr := make(chan error, 1) go func() { serveErr <- srv.Serve(ln) }() fmt.Fprintf(out, "Serving docs at http://%s (press Ctrl+C to stop)\n", ln.Addr()) watchErr := make(chan error, 1) watchCtx, stopWatch := context.WithCancel(ctx) defer stopWatch() go func() { watchErr <- s.watch(watchCtx, w) }() select { case <-ctx.Done(): case err := <-serveErr: return fmt.Errorf("docs:serve: %w", err) case err := <-watchErr: if err != nil { _ = srv.Close() return err } <-ctx.Done() } stopWatch() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) return nil } // checkServeAddr refuses a listen address whose host is not localhost or // a loopback IP, unless allowRemote is set. func checkServeAddr(addr string, allowRemote bool) error { host, _, err := net.SplitHostPort(addr) if err != nil { return fmt.Errorf("docs:serve: invalid --addr %q: %w", addr, err) } if allowRemote || host == "localhost" { return nil } if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { return nil } return fmt.Errorf("docs:serve: refusing to listen on %s: not a loopback address. Pass --allow-remote to serve on the network.", addr) } // server owns the served build directory and swaps it after each good // rebuild. type server struct { opts Options tmp string out io.Writer mu sync.RWMutex current string stale string builds int } func (s *server) dir() string { s.mu.RLock() defer s.mu.RUnlock() return s.current } // rebuild builds into a new directory and swaps it in only when the build // has no problems. The build before the previous one is removed, so an // in-flight request never loses its directory. func (s *server) rebuild() bool { s.builds++ dir := filepath.Join(s.tmp, fmt.Sprintf("build-%d", s.builds)) opts := s.opts opts.Out = dir _, problems, err := Build(opts) if err != nil || len(problems) > 0 { for _, p := range problems { fmt.Fprintln(s.out, p) } if err != nil { fmt.Fprintln(s.out, err) } _ = os.RemoveAll(dir) if s.dir() != "" { fmt.Fprintln(s.out, "docs:serve: build failed, still serving the previous version") } return false } s.mu.Lock() old := s.stale s.stale, s.current = s.current, dir s.mu.Unlock() if old != "" { _ = os.RemoveAll(old) } return true } // watch rebuilds on changes under Src, modules/ and every src= source // directory, debounced. The caller owns w and has added the first watches. func (s *server) watch(ctx context.Context, w *fsnotify.Watcher) error { var timer *time.Timer fire := make(chan struct{}, 1) for { select { case <-ctx.Done(): if timer != nil { timer.Stop() } return nil case ev, ok := <-w.Events: if !ok { return nil } if strings.HasPrefix(filepath.Base(ev.Name), ".") || ev.Op == fsnotify.Chmod { continue } if timer != nil { timer.Stop() } timer = time.AfterFunc(serveDebounce, func() { select { case fire <- struct{}{}: default: } }) case err, ok := <-w.Errors: if !ok { return nil } fmt.Fprintf(s.out, "docs:serve: watch: %v\n", err) case <-fire: if s.rebuild() { fmt.Fprintln(s.out, "docs:serve: rebuilt") } s.addWatches(w) } } } // addWatches watches every directory of Src and modules/ and the // directory of every src= reference. Adding a watched path again is a // no-op. func (s *server) addWatches(w *fsnotify.Watcher) { dirs := map[string]bool{} for _, base := range []string{s.opts.Src, filepath.Join(s.opts.Root, "modules")} { _ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { if err != nil || !d.IsDir() { return nil } if p != base && (strings.HasPrefix(d.Name(), ".") || d.Name() == "testdata" || d.Name() == "node_modules") { return filepath.SkipDir } dirs[p] = true return nil }) } pages, _, err := Pages(s.opts) if err == nil { for _, p := range pages { lines := strings.Split(string(p.Body), "\n") for _, f := range scanFences(lines) { if ref, ok := ParseSrc(f.info); ok { dirs[filepath.Join(s.opts.Root, filepath.FromSlash(path.Dir(ref.Path)))] = true } } } } for d := range dirs { if within(d, s.opts.Root) { _ = w.Add(d) } } } // Handler serves the built site in dir: files by path, a directory's // index.html, 404.html with status 404 for anything missing, and never a // dot-file. func Handler(dir string) http.Handler { return dirHandler(func() string { return dir }) } func dirHandler(dir func() string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } root := dir() clean := path.Clean("/" + r.URL.Path) for _, seg := range strings.Split(clean, "/") { if strings.HasPrefix(seg, ".") { notFound(w, root) return } } name := filepath.Join(root, filepath.FromSlash(clean)) info, err := os.Stat(name) if err == nil && info.IsDir() { name = filepath.Join(name, "index.html") info, err = os.Stat(name) } if err != nil || !info.Mode().IsRegular() || !within(name, root) { notFound(w, root) return } f, err := os.Open(name) if err != nil { notFound(w, root) return } defer f.Close() http.ServeContent(w, r, filepath.Base(name), info.ModTime(), f) }) } // notFound writes the site's 404.html with status 404. func notFound(w http.ResponseWriter, root string) { body, err := os.ReadFile(filepath.Join(root, "404.html")) if err != nil { http.NotFound(w, nil) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) _, _ = io.Copy(w, bytes.NewReader(body)) }