smesh.go raw

   1  package app
   2  
   3  import (
   4  	"context"
   5  	"embed"
   6  	"fmt"
   7  	"io/fs"
   8  	"net"
   9  	"net/http"
  10  	"strings"
  11  	"time"
  12  
  13  	"next.orly.dev/pkg/lol/log"
  14  )
  15  
  16  //go:embed smesh/dist
  17  var smeshFS embed.FS
  18  
  19  // SmeshServer serves the embedded Smesh web client on a dedicated port.
  20  type SmeshServer struct {
  21  	server   *http.Server
  22  	listener net.Listener
  23  	port     int
  24  }
  25  
  26  // NewSmeshServer creates a new smesh HTTP server on the given port.
  27  func NewSmeshServer(port int) *SmeshServer {
  28  	return &SmeshServer{port: port}
  29  }
  30  
  31  // Start begins serving the embedded smesh SPA.
  32  func (s *SmeshServer) Start(ctx context.Context) error {
  33  	// Extract the dist/ subtree from the embedded filesystem
  34  	webDist, err := fs.Sub(smeshFS, "smesh/dist")
  35  	if err != nil {
  36  		return fmt.Errorf("failed to load embedded smesh app: %w", err)
  37  	}
  38  
  39  	fileServer := http.FileServer(http.FS(webDist))
  40  
  41  	mux := http.NewServeMux()
  42  	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  43  		// For SPA routing: serve index.html for paths that don't match a real file.
  44  		// Real assets (JS, CSS, images) are served directly.
  45  		path := r.URL.Path
  46  		if path == "/" {
  47  			fileServer.ServeHTTP(w, r)
  48  			return
  49  		}
  50  
  51  		// Check if the path maps to a real file in the embedded FS
  52  		cleanPath := strings.TrimPrefix(path, "/")
  53  		if f, err := webDist.Open(cleanPath); err == nil {
  54  			f.Close()
  55  			fileServer.ServeHTTP(w, r)
  56  			return
  57  		}
  58  
  59  		// SPA fallback: serve index.html for client-side routing
  60  		r.URL.Path = "/"
  61  		fileServer.ServeHTTP(w, r)
  62  	})
  63  
  64  	addr := fmt.Sprintf("127.0.0.1:%d", s.port)
  65  	s.server = &http.Server{
  66  		Addr:         addr,
  67  		Handler:      mux,
  68  		ReadTimeout:  15 * time.Second,
  69  		WriteTimeout: 15 * time.Second,
  70  		IdleTimeout:  60 * time.Second,
  71  	}
  72  
  73  	s.listener, err = net.Listen("tcp", addr)
  74  	if err != nil {
  75  		return fmt.Errorf("smesh: failed to listen on %s: %w", addr, err)
  76  	}
  77  
  78  	// Graceful shutdown on context cancellation
  79  	go func() {
  80  		<-ctx.Done()
  81  		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  82  		defer cancel()
  83  		if err := s.server.Shutdown(shutdownCtx); err != nil {
  84  			log.W.F("smesh server shutdown error: %v", err)
  85  		}
  86  	}()
  87  
  88  	log.I.F("smesh web client serving on http://%s", addr)
  89  
  90  	go func() {
  91  		if err := s.server.Serve(s.listener); err != nil && err != http.ErrServerClosed {
  92  			log.E.F("smesh server error: %v", err)
  93  		}
  94  	}()
  95  
  96  	return nil
  97  }
  98  
  99  // Stop shuts down the smesh server.
 100  func (s *SmeshServer) Stop() {
 101  	if s.server != nil {
 102  		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 103  		defer cancel()
 104  		s.server.Shutdown(ctx)
 105  	}
 106  }
 107