浏览代码

#237 reload on config change

amueller 9 月之前
父节点
当前提交
46ad4253ac
共有 6 个文件被更改,包括 187 次插入31 次删除
  1. 1 0
      .gitignore
  2. 5 0
      go.mod
  3. 8 0
      go.sum
  4. 22 0
      internal/assets/templates/document.html
  5. 76 15
      internal/glance/glance.go
  6. 75 16
      internal/glance/main.go

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@
 /build
 /build
 /playground
 /playground
 glance*.yml
 glance*.yml
+docker-compose.yml

+ 5 - 0
go.mod

@@ -8,9 +8,14 @@ require (
 	gopkg.in/yaml.v3 v3.0.1
 	gopkg.in/yaml.v3 v3.0.1
 )
 )
 
 
+require golang.org/x/sys v0.22.0 // indirect
+
 require (
 require (
 	github.com/PuerkitoBio/goquery v1.9.2 // indirect
 	github.com/PuerkitoBio/goquery v1.9.2 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
+	github.com/fsnotify/fsnotify v1.7.0
+	github.com/gorilla/mux v1.8.1
+	github.com/gorilla/websocket v1.5.3
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect

+ 8 - 0
go.sum

@@ -5,7 +5,13 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
 github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
@@ -45,6 +51,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=

+ 22 - 0
internal/assets/templates/document.html

@@ -20,5 +20,27 @@
 </head>
 </head>
 <body>
 <body>
 {{ template "document-body" . }}
 {{ template "document-body" . }}
+<script>
+    document.addEventListener("DOMContentLoaded", function() {
+        function createWebSocket() {
+            let host = window.location.hostname;
+            let port = window.location.protocol === "https:" ? (window.location.port || 443) : (window.location.port || 80);
+            let ws = new WebSocket("ws://" + host + ":" + port + "/ws");
+            console.log("WebSocket connection established");
+            ws.onmessage = function(event) {
+                if (event.data === "reload") {
+                    location.reload();
+                }
+            };
+    
+            ws.onclose = function() {
+                console.log("WebSocket connection closed, attempting to reconnect...");
+                setTimeout(createWebSocket, 1000);
+            };
+        }
+    
+        createWebSocket();
+    });
+</script>
 </body>
 </body>
 </html>
 </html>

+ 76 - 15
internal/glance/glance.go

@@ -16,17 +16,29 @@ import (
 
 
 	"github.com/glanceapp/glance/internal/assets"
 	"github.com/glanceapp/glance/internal/assets"
 	"github.com/glanceapp/glance/internal/widget"
 	"github.com/glanceapp/glance/internal/widget"
+	"github.com/gorilla/mux"
+	"github.com/gorilla/websocket"
 )
 )
 
 
 var buildVersion = "dev"
 var buildVersion = "dev"
 
 
 var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
 var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
 
 
+var upgrader = websocket.Upgrader{
+	CheckOrigin: func(r *http.Request) bool {
+		return true
+	},
+}
+
+var wsClients = make(map[*websocket.Conn]bool)
+var wsBroadcast = make(chan []byte)
+
 type Application struct {
 type Application struct {
 	Version    string
 	Version    string
 	Config     Config
 	Config     Config
 	slugToPage map[string]*Page
 	slugToPage map[string]*Page
 	widgetByID map[uint64]widget.Widget
 	widgetByID map[uint64]widget.Widget
+	server     *http.Server
 }
 }
 
 
 type Theme struct {
 type Theme struct {
@@ -173,7 +185,7 @@ func NewApplication(config *Config) (*Application, error) {
 }
 }
 
 
 func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
 func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
-	page, exists := a.slugToPage[r.PathValue("page")]
+	page, exists := a.slugToPage[mux.Vars(r)["page"]]
 
 
 	if !exists {
 	if !exists {
 		a.HandleNotFound(w, r)
 		a.HandleNotFound(w, r)
@@ -198,7 +210,7 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
 }
 }
 
 
 func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
 func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
-	page, exists := a.slugToPage[r.PathValue("page")]
+	page, exists := a.slugToPage[mux.Vars(r)["page"]]
 
 
 	if !exists {
 	if !exists {
 		a.HandleNotFound(w, r)
 		a.HandleNotFound(w, r)
@@ -242,7 +254,7 @@ func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
 }
 }
 
 
 func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
 func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
-	widgetValue := r.PathValue("widget")
+	widgetValue := mux.Vars(r)["widget"]
 
 
 	widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
 	widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
 
 
@@ -268,19 +280,23 @@ func (a *Application) AssetPath(asset string) string {
 func (a *Application) Serve() error {
 func (a *Application) Serve() error {
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add HTTPS support
 	// TODO: add HTTPS support
-	mux := http.NewServeMux()
+	router := mux.NewRouter()
+
+	// In gorilla/mux, routes are matched in the order they are registered,
+	// so more specific routes should be registered before more general ones
+	router.HandleFunc("/ws", a.handleWebSocket)
 
 
-	mux.HandleFunc("GET /{$}", a.HandlePageRequest)
-	mux.HandleFunc("GET /{page}", a.HandlePageRequest)
+	router.HandleFunc("/{page}", a.HandlePageRequest).Methods("GET")
 
 
-	mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
-	mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
-	mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
+	router.HandleFunc("/api/pages/{page}/content/", a.HandlePageContentRequest).Methods("GET")
+	router.HandleFunc("/api/widgets/{widget}/{path:.*}", a.HandleWidgetRequest)
+	router.HandleFunc("/api/healthz", func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(http.StatusOK)
 		w.WriteHeader(http.StatusOK)
-	})
+	}).Methods("GET")
+	router.HandleFunc("/", a.HandlePageRequest).Methods("GET")
 
 
-	mux.Handle(
-		fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
+	router.Handle(
+		fmt.Sprintf("/static/%s/{path:.*}", a.Config.Server.AssetsHash),
 		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
 		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
 	)
 	)
 
 
@@ -293,16 +309,61 @@ func (a *Application) Serve() error {
 
 
 		slog.Info("Serving assets", "path", absAssetsPath)
 		slog.Info("Serving assets", "path", absAssetsPath)
 		assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
 		assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
-		mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
+		router.Handle("/assets/{path:.*}", http.StripPrefix("/assets/", assetsFS))
 	}
 	}
 
 
-	server := http.Server{
+	server := &http.Server{
 		Addr:    fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port),
 		Addr:    fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port),
-		Handler: mux,
+		Handler: router,
 	}
 	}
 
 
+	a.server = server
+
+	go a.handleWebSocketMessages()
+
 	a.Config.Server.StartedAt = time.Now()
 	a.Config.Server.StartedAt = time.Now()
 	slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
 	slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
 
 
 	return server.ListenAndServe()
 	return server.ListenAndServe()
 }
 }
+
+func (a *Application) handleWebSocket(w http.ResponseWriter, r *http.Request) {
+	conn, err := upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		fmt.Printf("failed to upgrade to websocket: %v\n", err)
+		return
+	}
+	defer conn.Close()
+
+	wsClients[conn] = true
+
+	for {
+		_, _, err := conn.ReadMessage()
+		if err != nil {
+			delete(wsClients, conn)
+			break
+		}
+	}
+}
+
+func (a *Application) handleWebSocketMessages() {
+	for {
+		msg := <-wsBroadcast
+		for client := range wsClients {
+			err := client.WriteMessage(websocket.TextMessage, msg)
+			if err != nil {
+				client.Close()
+				delete(wsClients, client)
+			}
+		}
+	}
+}
+
+func (a *Application) Stop() error {
+	if a.server != nil {
+		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		return a.server.Shutdown(ctx)
+	}
+	return nil
+}

+ 75 - 16
internal/glance/main.go

@@ -3,6 +3,13 @@ package glance
 import (
 import (
 	"fmt"
 	"fmt"
 	"os"
 	"os"
+
+	"github.com/fsnotify/fsnotify"
+)
+
+var (
+	currentApp *Application
+	done       chan bool
 )
 )
 
 
 func Main() int {
 func Main() int {
@@ -13,34 +20,86 @@ func Main() int {
 		return 1
 		return 1
 	}
 	}
 
 
-	configFile, err := os.Open(options.ConfigPath)
+	if options.Intent == CliIntentServe {
+		err := startWatcherAndApp(options.ConfigPath)
+		if err != nil {
+			fmt.Println(err)
+			return 1
+		}
+	}
+
+	return 0
+}
+
+func startWatcherAndApp(configPath string) error {
+	done = make(chan bool)
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		return fmt.Errorf("failed to create file watcher: %v", err)
+	}
+	defer watcher.Close()
+
+	go func() {
+		for {
+			select {
+			case event, ok := <-watcher.Events:
+				if !ok {
+					return
+				}
+				if event.Op&fsnotify.Write == fsnotify.Write {
+					fmt.Println("config file modified, restarting application...")
+					if currentApp != nil {
+						wsBroadcast <- []byte("reload")
+						if err := currentApp.Stop(); err != nil {
+							fmt.Printf("failed to shutdown application: %v\n", err)
+						}
+					}
+					startWatcherAndApp(configPath)
+				}
+			case err, ok := <-watcher.Errors:
+				if !ok {
+					return
+				}
+				fmt.Printf("error watching config file: %v\n", err)
+			}
+		}
+	}()
+
+	err = watcher.Add(configPath)
+	if err != nil {
+		return fmt.Errorf("failed to watch config file: %v", err)
+	}
+
+	restartApplication(configPath)
+	<-done
+
+	return nil
+}
+
+func restartApplication(configPath string) {
 
 
+	configFile, err := os.Open(configPath)
 	if err != nil {
 	if err != nil {
 		fmt.Printf("failed opening config file: %v\n", err)
 		fmt.Printf("failed opening config file: %v\n", err)
-		return 1
+		return
 	}
 	}
 
 
 	config, err := NewConfigFromYml(configFile)
 	config, err := NewConfigFromYml(configFile)
 	configFile.Close()
 	configFile.Close()
-
 	if err != nil {
 	if err != nil {
 		fmt.Printf("failed parsing config file: %v\n", err)
 		fmt.Printf("failed parsing config file: %v\n", err)
-		return 1
+		return
 	}
 	}
 
 
-	if options.Intent == CliIntentServe {
-		app, err := NewApplication(config)
+	app, err := NewApplication(config)
+	if err != nil {
+		fmt.Printf("failed creating application: %v\n", err)
+		return
+	}
 
 
-		if err != nil {
-			fmt.Printf("failed creating application: %v\n", err)
-			return 1
-		}
+	currentApp = app
 
 
-		if err := app.Serve(); err != nil {
-			fmt.Printf("http server error: %v\n", err)
-			return 1
-		}
+	if err := app.Serve(); err != nil {
+		fmt.Printf("http server error: %v\n", err)
 	}
 	}
-
-	return 0
 }
 }