From 452d55d04c809b582c72acf1253a6484841923d3 Mon Sep 17 00:00:00 2001 From: Ralph Ocdol Date: Tue, 29 Oct 2024 17:53:44 +0800 Subject: [PATCH] feat: Merge upstream PR #241 auto reload config (#10) --- .gitignore | 1 + go.mod | 5 ++ go.sum | 8 +++ internal/assets/templates/document.html | 23 ++++++ internal/glance/glance.go | 93 +++++++++++++++++++----- internal/glance/main.go | 94 +++++++++++++++++++++---- 6 files changed, 193 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index f7e0f6c2..dbc745c1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /build /playground glance*.yml +docker-compose.yml diff --git a/go.mod b/go.mod index 56b35a5a..9c2fc81d 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,14 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require golang.org/x/sys v0.25.0 // indirect + require ( github.com/PuerkitoBio/goquery v1.10.0 // 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/mmcdole/goxpp v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index be337128..6092a6e4 100644 --- a/go.sum +++ b/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= @@ -52,6 +58,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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/internal/assets/templates/document.html b/internal/assets/templates/document.html index 66b73e70..3f9769f1 100644 --- a/internal/assets/templates/document.html +++ b/internal/assets/templates/document.html @@ -20,5 +20,28 @@ {{ template "document-body" . }} + diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 083b9b46..1ea06f2a 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -17,17 +17,29 @@ import ( "github.com/glanceapp/glance/internal/assets" "github.com/glanceapp/glance/internal/widget" + "github.com/gorilla/mux" + "github.com/gorilla/websocket" ) var buildVersion = "dev" 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 { Version string Config Config slugToPage map[string]*Page widgetByID map[uint64]widget.Widget + server *http.Server } type Theme struct { @@ -186,7 +198,7 @@ func NewApplication(config *Config) (*Application, error) { } 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 { a.HandleNotFound(w, r) @@ -211,7 +223,7 @@ func (a *Application) HandlePageRequest(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 { a.HandleNotFound(w, r) @@ -255,7 +267,7 @@ func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H } 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) @@ -281,21 +293,25 @@ func (a *Application) AssetPath(asset string) string { func (a *Application) Serve() error { // TODO: add gzip support, static files must have their gzipped contents cached // 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) - }) + }).Methods("GET") + router.HandleFunc("/", a.HandlePageRequest).Methods("GET") - mux.HandleFunc(fmt.Sprintf("GET /static/%s/manifest.json", a.Config.Server.AssetsHash), a.HandleManifestRequest) + router.HandleFunc(fmt.Sprintf("GET /static/%s/manifest.json", a.Config.Server.AssetsHash), a.HandleManifestRequest) - 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)), ) @@ -308,20 +324,65 @@ func (a *Application) Serve() error { slog.Info("Serving assets", "path", absAssetsPath) 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), - Handler: mux, + Handler: router, } + a.server = server + + go a.handleWebSocketMessages() + 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) 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 +} + func (a *Application) HandleManifestRequest(w http.ResponseWriter, r *http.Request) { manifest := map[string]interface{}{ "name": func() string { diff --git a/internal/glance/main.go b/internal/glance/main.go index 426c41fa..89680f54 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -3,6 +3,14 @@ package glance import ( "fmt" "os" + "time" + + "github.com/fsnotify/fsnotify" +) + +var ( + currentApp *Application + done chan bool ) func Main() int { @@ -13,34 +21,90 @@ func Main() int { return 1 } - configFile, err := os.Open(options.ConfigPath) + if options.Intent == CliIntentServe { + err := startWatcherAndApp(options.ConfigPath, false) + if err != nil { + fmt.Println(err) + return 1 + } + } + + return 0 +} + +func startWatcherAndApp(configPath string, sendReload bool) 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 { + if err := currentApp.Stop(); err != nil { + fmt.Printf("failed to shutdown application: %v\n", err) + } + time.Sleep(1 * time.Second) // give it enough time to shutdown + } + startWatcherAndApp(configPath, true) + } + 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, sendReload) + <-done + return nil +} + +func restartApplication(configPath string, sendReload bool) { + configFile, err := os.Open(configPath) if err != nil { fmt.Printf("failed opening config file: %v\n", err) - return 1 + return } config, err := NewConfigFromYml(configFile) configFile.Close() - if err != nil { 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 sendReload { + wsBroadcast <- []byte("reload") + } + + if err := app.Serve(); err != nil { + fmt.Printf("http server error: %v\n", err) } - return 0 }