├── .github └── workflows │ ├── release.yml │ └── validate.yml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── cmd ├── serve │ ├── handlers.go │ ├── serve.go │ └── serve_test.go ├── setup.go ├── setup_test.go ├── types.go └── version │ ├── version.go │ └── version_test.go ├── flags.go ├── go.mod ├── go.sum ├── internal └── wsinject │ ├── delta_streamer.go │ ├── delta_streamer.ws.go │ ├── delta_streamer_test.go │ ├── wsinject.go │ └── wsinject_test.go ├── main.go ├── main_test.go └── setup.sh /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Simple Go Pipeline - release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | jobs: 8 | call-workflow: 9 | uses: baalimago/simple-go-pipeline/.github/workflows/release.yml@v0.2.5 10 | with: 11 | go-version: '1.22' 12 | project-name: wd-41 13 | branch: main 14 | version-var: "github.com/baalimago/wd-41/cmd/version.BUILD_VERSION" 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Simple Go Pipeline - validate 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | call-workflow: 11 | uses: baalimago/simple-go-pipeline/.github/workflows/validate.yml@main 12 | with: 13 | go-version: '1.22' 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.out 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run no args", 6 | "type": "go", 7 | "request": "launch", 8 | "program": "${workspaceFolder}", 9 | "args": [], 10 | "env": { 11 | "NO_COLOR": "true" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Lorentz Kinde 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # (w)eb (d)evelopment-(41) 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/baalimago/wd-41)](https://goreportcard.com/report/github.com/baalimago/wd-41) 4 | [![wakatime](https://wakatime.com/badge/user/018cc8d2-3fd9-47ef-81dc-e4ad645d5f34/project/3bc921ec-dc23-4222-bf00-578f2eda0cbd.svg)](https://wakatime.com/badge/user/018cc8d2-3fd9-47ef-81dc-e4ad645d5f34/project/3bc921ec-dc23-4222-bf00-578f2eda0cbd) 5 | 6 | Test coverage: 76.640% 😌👏 7 | 8 | This is a simple static webserver which live reloads your web-browser on changes to the hosted files. 9 | 10 | ## Usage 11 | 12 | `wd-41 s|serve ` or `wd-41 s|serve` for hosting the current work directory 13 | 14 | ## Getting started 15 | 16 | ```bash 17 | go install github.com/baalimago/wd-41@latest 18 | ``` 19 | 20 | You may also use the setup script: 21 | 22 | ```bash 23 | curl -fsSL https://raw.githubusercontent.com/baalimago/wd-41/main/setup.sh | sh 24 | ``` 25 | 26 | ## Architecture 27 | 28 | 1. First the content of the website is copied to a temporary directory, this is the _mirrored content_ 29 | 1. Every mirrored file is inspected for type, if it's text/html, a `delta-streamer.js` script is injected 30 | 1. The web server is started, hosting the _mirrored_ content 31 | 1. The `delta-streamer.js` in turn sets up a websocket connection to the wd-41 webserver 32 | 1. The original file system is monitored, on any file changes: 33 | 1. the new file is copied to the mirror (including injections) 34 | 1. the file name is propagated to the browser via the websocket 35 | 1. The `delta-streamer.js` script then checks if the current window origin is the updated file. If so, it reloads the page. 36 | 37 | ``` 38 | ┌───────────────┐ 39 | │ Web Developer │ 40 | └───────┬───────┘ 41 | │ 42 | [writes ] 43 | │ 44 | ▼ 45 | ┌─────────────────────────────┐ ┌─────────────────────┐ 46 | │ website-directory/ │ │ file system notify │ 47 | └─────────────┬───────────────┘ └─────────┬───────────┘ 48 | │ │ 49 | │ [update mirrored content] 50 | ▼ │ 51 | ┌────────────────────┐ │ 52 | │ ws-script injector │◄──────────────────────┘ 53 | └─────────┬──────────┘ 54 | │ 55 | │ 56 | ▼ 57 | ┌────────────────────────┐ 58 | │ tmp-abcd1234/ │ 59 | └───────────┬────────────┘ 60 | │ 61 | [serves ] 62 | │ ┌────────────────────────┐ 63 | ▼ │ Browser │ 64 | ┌──────────────────────────────┐ │ │ 65 | │ Web Server │ │ ┌────┐ ┌───────────┐ │ 66 | │ [localhost:/] │◄───[reload────┼─►│ ws │ │ │ │ 67 | └──────────────────────────────┘ page] │ └────┘ └───────────┘ │ 68 | │ │ 69 | └────────────────────────┘ 70 | ``` 71 | -------------------------------------------------------------------------------- /cmd/serve/handlers.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/baalimago/go_away_boilerplate/pkg/ancli" 7 | ) 8 | 9 | func slogHandler(next http.Handler) http.Handler { 10 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 | ancli.PrintfOK("%s - %s", r.Method, r.URL.Path) 12 | next.ServeHTTP(w, r) 13 | }) 14 | } 15 | 16 | func cacheHandler(next http.Handler, cacheControl string) http.Handler { 17 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | w.Header().Add("Cache-Control", cacheControl) 19 | next.ServeHTTP(w, r) 20 | }) 21 | } 22 | 23 | func crossOriginIsolationHandler(next http.Handler) http.Handler { 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | w.Header().Add("Cross-Origin-Opener-Policy", "same-origin") 26 | w.Header().Add("Cross-Origin-Embedder-Policy", "require-corp") 27 | next.ServeHTTP(w, r) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /cmd/serve/serve.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "path" 11 | 12 | "github.com/baalimago/go_away_boilerplate/pkg/ancli" 13 | "github.com/baalimago/wd-41/internal/wsinject" 14 | "golang.org/x/net/websocket" 15 | ) 16 | 17 | type Fileserver interface { 18 | Setup(pathToMaster string) (string, error) 19 | Start(ctx context.Context) error 20 | WsHandler(ws *websocket.Conn) 21 | } 22 | 23 | type command struct { 24 | binPath string 25 | // master, as in adjective 'master record' non-slavery kind 26 | masterPath string 27 | mirrorPath string 28 | port *int 29 | wsPath *string 30 | forceReload *bool 31 | flagset *flag.FlagSet 32 | fileserver Fileserver 33 | 34 | cacheControl *string 35 | tlsCertPath *string 36 | tlsKeyPath *string 37 | } 38 | 39 | func Command() *command { 40 | r, _ := os.Executable() 41 | return &command{ 42 | binPath: r, 43 | } 44 | } 45 | 46 | func (c *command) Setup() error { 47 | relPath := "" 48 | if len(c.flagset.Args()) == 0 { 49 | wd, err := os.Getwd() 50 | if err != nil { 51 | return fmt.Errorf("failed to get exec path: %w", err) 52 | } 53 | relPath = wd 54 | } else { 55 | relPath = c.flagset.Arg(0) 56 | } 57 | c.masterPath = path.Clean(relPath) 58 | 59 | if c.masterPath != "" { 60 | expectTLS := *c.tlsCertPath != "" && *c.tlsKeyPath != "" 61 | c.fileserver = wsinject.NewFileServer(*c.port, *c.wsPath, *c.forceReload, expectTLS) 62 | mirrorPath, err := c.fileserver.Setup(c.masterPath) 63 | if err != nil { 64 | return fmt.Errorf("failed to setup websocket injected mirror filesystem: %v", err) 65 | } 66 | c.mirrorPath = mirrorPath 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (c *command) Run(ctx context.Context) error { 73 | mux := http.NewServeMux() 74 | fsh := http.FileServer(http.Dir(c.mirrorPath)) 75 | fsh = slogHandler(fsh) 76 | fsh = cacheHandler(fsh, *c.cacheControl) 77 | fsh = crossOriginIsolationHandler(fsh) 78 | mux.Handle("/", fsh) 79 | 80 | ancli.PrintfOK("setting up websocket host on path: '%v'", *c.wsPath) 81 | mux.Handle(*c.wsPath, websocket.Handler(c.fileserver.WsHandler)) 82 | 83 | s := http.Server{ 84 | Addr: fmt.Sprintf(":%v", *c.port), 85 | Handler: mux, 86 | ReadTimeout: 0, 87 | } 88 | serverErrChan := make(chan error, 1) 89 | fsErrChan := make(chan error, 1) 90 | go func() { 91 | serveTLS := *c.tlsCertPath != "" && *c.tlsKeyPath != "" 92 | 93 | hostname := "localhost" // default hostname 94 | protocol := "http" 95 | if serveTLS { 96 | protocol = "https" 97 | } 98 | baseURL := fmt.Sprintf("%s://%s:%d", protocol, hostname, *c.port) 99 | 100 | ancli.PrintfOK("Server started successfully:") 101 | ancli.PrintfOK("- URL: %s", baseURL) 102 | ancli.PrintfOK("- Serving directory: '%v'", c.masterPath) 103 | ancli.PrintfOK("- Mirror directory: '%v'", c.mirrorPath) 104 | if serveTLS { 105 | ancli.PrintfOK("- TLS enabled (cert: '%v', key: '%v')", *c.tlsCertPath, *c.tlsKeyPath) 106 | } else { 107 | ancli.PrintfOK("- TLS disabled") 108 | } 109 | 110 | var err error 111 | if serveTLS { 112 | err = s.ListenAndServeTLS(*c.tlsCertPath, *c.tlsKeyPath) 113 | } else { 114 | err = s.ListenAndServe() 115 | } 116 | if !errors.Is(err, http.ErrServerClosed) { 117 | serverErrChan <- err 118 | } 119 | }() 120 | go func() { 121 | ancli.PrintOK("starting fsnotify file detector") 122 | err := c.fileserver.Start(ctx) 123 | if err != nil { 124 | fsErrChan <- err 125 | } 126 | }() 127 | var retErr error 128 | select { 129 | case <-ctx.Done(): 130 | case serveErr := <-serverErrChan: 131 | retErr = serveErr 132 | break 133 | case fsErr := <-fsErrChan: 134 | retErr = fsErr 135 | break 136 | } 137 | ancli.PrintNotice("initiating webserver graceful shutdown") 138 | s.Shutdown(ctx) 139 | ancli.PrintOK("shutdown complete") 140 | return retErr 141 | } 142 | 143 | func (c *command) Help() string { 144 | return "Serve some filesystem. Set the directory as the second argument: wd-41 serve . If omitted, current wd will be used." 145 | } 146 | 147 | func (c *command) Describe() string { 148 | return fmt.Sprintf("a webserver. Usage: '%v serve '. If is left unfilled, current pwd will be used.", c.binPath) 149 | } 150 | 151 | func (c *command) Flagset() *flag.FlagSet { 152 | fs := flag.NewFlagSet("server", flag.ExitOnError) 153 | c.port = fs.Int("port", 8080, "port to serve http server on") 154 | c.wsPath = fs.String("wsPort", "/delta-streamer-ws", "the path which the delta streamer websocket should be hosted on") 155 | c.forceReload = fs.Bool("forceReload", false, "set to true if you wish to reload all attached browser pages on any file change") 156 | c.cacheControl = fs.String("cacheControl", "no-cache", "set to configure the cache-control header") 157 | c.tlsCertPath = fs.String("tlsCertPath", "", "set to a path to a cert, requires tlsKeyPath to be set") 158 | c.tlsKeyPath = fs.String("tlsKeyPath", "", "set to a path to a key, requires tlsCertPath to be set") 159 | c.flagset = fs 160 | return fs 161 | } 162 | -------------------------------------------------------------------------------- /cmd/serve/serve_test.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | "time" 11 | 12 | "github.com/baalimago/go_away_boilerplate/pkg/testboil" 13 | "golang.org/x/net/websocket" 14 | ) 15 | 16 | func Test_Setup(t *testing.T) { 17 | tmpDir := t.TempDir() 18 | t.Run("it should set masterPath to second argument", func(t *testing.T) { 19 | want := tmpDir 20 | c := command{ 21 | masterPath: "pre", 22 | } 23 | given := []string{want} 24 | err := c.Flagset().Parse(given) 25 | if err != nil { 26 | t.Fatalf("failed to parse flagset: %v", err) 27 | } 28 | c.Setup() 29 | got := c.masterPath 30 | if got != want { 31 | t.Fatalf("expected: %v, got: %v", want, got) 32 | } 33 | }) 34 | 35 | t.Run("it should set port arg", func(t *testing.T) { 36 | want := 9090 37 | c := command{} 38 | givenArgs := []string{"-port", "9090"} 39 | err := c.Flagset().Parse(givenArgs) 40 | if err != nil { 41 | t.Fatalf("failed to parse flagset: %v", err) 42 | } 43 | err = c.Setup() 44 | if err != nil { 45 | t.Fatalf("failed to setup: %v", err) 46 | } 47 | 48 | got := *c.port 49 | if got != want { 50 | t.Fatalf("expected: %v, got: %v", want, got) 51 | } 52 | }) 53 | 54 | t.Run("it should set cacheControl arg", func(t *testing.T) { 55 | want := "test" 56 | c := command{} 57 | givenArgs := []string{"-cacheControl", want} 58 | err := c.Flagset().Parse(givenArgs) 59 | if err != nil { 60 | t.Fatalf("failed to parse flagset: %v", err) 61 | } 62 | err = c.Setup() 63 | if err != nil { 64 | t.Fatalf("failed to setup: %v", err) 65 | } 66 | 67 | got := *c.cacheControl 68 | if got != want { 69 | t.Fatalf("expected: %v, got: %v", want, got) 70 | } 71 | }) 72 | } 73 | 74 | type mockFileServer struct{} 75 | 76 | func (m *mockFileServer) Setup(pathToMaster string) (string, error) { 77 | return "/mock/mirror/path", nil 78 | } 79 | 80 | func (m *mockFileServer) Start(ctx context.Context) error { 81 | <-ctx.Done() 82 | return ctx.Err() 83 | } 84 | 85 | func (m *mockFileServer) WsHandler(ws *websocket.Conn) {} 86 | 87 | func TestRun(t *testing.T) { 88 | setup := func() command { 89 | cmd := command{} 90 | cmd.fileserver = &mockFileServer{} 91 | fs := cmd.Flagset() 92 | fs.Parse([]string{"--port=8081", "--wsPort=/test-ws"}) 93 | 94 | err := cmd.Setup() 95 | if err != nil { 96 | t.Fatalf("Setup failed: %v", err) 97 | } 98 | return cmd 99 | } 100 | 101 | t.Run("it should setup websocket handler on wsPort", func(t *testing.T) { 102 | cmd := setup() 103 | ctx, ctxCancel := context.WithCancel(context.Background()) 104 | 105 | ready := make(chan struct{}) 106 | go func() { 107 | close(ready) 108 | err := cmd.Run(ctx) 109 | if err != nil { 110 | t.Errorf("Run returned error: %v", err) 111 | } 112 | }() 113 | 114 | t.Cleanup(ctxCancel) 115 | 116 | <-ready 117 | // Test if the HTTP server is working 118 | resp, err := http.Get("http://localhost:8081/") 119 | if err != nil { 120 | t.Fatalf("Failed to send GET request: %v", err) 121 | } 122 | t.Cleanup(func() { resp.Body.Close() }) 123 | 124 | if resp.StatusCode != http.StatusOK { 125 | t.Fatalf("Expected status OK, got: %v", resp.Status) 126 | } 127 | 128 | // Test the websocket handler 129 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 130 | s := websocket.Server{Handler: cmd.fileserver.WsHandler} 131 | s.ServeHTTP(w, r) 132 | })) 133 | 134 | t.Cleanup(func() { server.Close() }) 135 | 136 | wsURL := "ws" + server.URL[len("http"):] 137 | ws, err := websocket.Dial(wsURL+"/test-ws", "", "http://localhost/") 138 | if err != nil { 139 | t.Fatalf("websocket dial failed: %v", err) 140 | } 141 | t.Cleanup(func() { ws.Close() }) 142 | }) 143 | 144 | t.Run("it should respond with correct headers", func(t *testing.T) { 145 | cmd := setup() 146 | ctx, ctxCancel := context.WithCancel(context.Background()) 147 | t.Cleanup(ctxCancel) 148 | wantCacheControl := "test" 149 | port := 13337 150 | cmd.cacheControl = &wantCacheControl 151 | cmd.port = &port 152 | 153 | ready := make(chan struct{}) 154 | go func() { 155 | close(ready) 156 | err := cmd.Run(ctx) 157 | if err != nil { 158 | t.Errorf("Run returned error: %v", err) 159 | } 160 | }() 161 | <-ready 162 | time.Sleep(time.Millisecond) 163 | resp, err := http.Get(fmt.Sprintf("http://localhost:%v", port)) 164 | if err != nil { 165 | t.Fatal(err) 166 | } 167 | t.Run("cache-control", func(t *testing.T) { 168 | got := resp.Header.Get("Cache-Control") 169 | testboil.FailTestIfDiff(t, got, wantCacheControl) 170 | }) 171 | 172 | t.Run("Cross-Origin-Opener-Policy", func(t *testing.T) { 173 | got := resp.Header.Get("Cross-Origin-Opener-Policy") 174 | testboil.FailTestIfDiff(t, got, "same-origin") 175 | }) 176 | 177 | t.Run("Cross-Origin-Embedder-Policy", func(t *testing.T) { 178 | got := resp.Header.Get("Cross-Origin-Embedder-Policy") 179 | testboil.FailTestIfDiff(t, got, "require-corp") 180 | }) 181 | }) 182 | 183 | t.Run("it should serve with tls if cert and key is specified", func(t *testing.T) { 184 | cmd := setup() 185 | ctx, ctxCancel := context.WithCancel(context.Background()) 186 | t.Cleanup(ctxCancel) 187 | testCert := testboil.CreateTestFile(t, "cert.pem") 188 | testCert.Write([]byte(`-----BEGIN CERTIFICATE----- 189 | MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw 190 | DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow 191 | EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d 192 | 7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B 193 | 5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr 194 | BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 195 | NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l 196 | Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc 197 | 6MF9+Yw1Yy0t 198 | -----END CERTIFICATE-----`)) 199 | testKey := testboil.CreateTestFile(t, "key.pem") 200 | testKey.Write([]byte(`-----BEGIN EC PRIVATE KEY----- 201 | MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49 202 | AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q 203 | EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA== 204 | -----END EC PRIVATE KEY-----`)) 205 | port := 13337 206 | cmd.port = &port 207 | certPath := testCert.Name() 208 | cmd.tlsCertPath = &certPath 209 | keyPath := testKey.Name() 210 | cmd.tlsKeyPath = &keyPath 211 | 212 | ready := make(chan struct{}) 213 | go func() { 214 | close(ready) 215 | err := cmd.Run(ctx) 216 | if err != nil { 217 | t.Errorf("Run returned error: %v", err) 218 | } 219 | }() 220 | <-ready 221 | time.Sleep(time.Millisecond) 222 | 223 | // Cert above expired in 2018 224 | transport := &http.Transport{ 225 | TLSClientConfig: &tls.Config{ 226 | InsecureSkipVerify: true, 227 | }, 228 | } 229 | 230 | client := &http.Client{ 231 | Transport: transport, 232 | } 233 | resp, err := client.Get(fmt.Sprintf("https://localhost:%v", port)) 234 | if err != nil { 235 | t.Fatal(err) 236 | } 237 | if resp.StatusCode != http.StatusOK { 238 | t.Fatalf("expected status code: %v", resp.StatusCode) 239 | } 240 | }) 241 | } 242 | -------------------------------------------------------------------------------- /cmd/setup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "text/tabwriter" 8 | 9 | "github.com/baalimago/wd-41/cmd/serve" 10 | "github.com/baalimago/wd-41/cmd/version" 11 | ) 12 | 13 | var commands = map[string]Command{ 14 | "s|serve": serve.Command(), 15 | "v|version": version.Command(), 16 | } 17 | 18 | func Parse(args []string) (Command, error) { 19 | if len(args) == 1 { 20 | return nil, ErrNoArgs 21 | } 22 | cmdCandidate := "" 23 | for _, arg := range args[1:] { 24 | if isHelp(arg) { 25 | return nil, ErrHelpful 26 | } 27 | isFlag := strings.HasPrefix(arg, "-") 28 | if isFlag { 29 | continue 30 | } 31 | // Break on first non-flag 32 | cmdCandidate = arg 33 | break 34 | } 35 | for cmdNameWithShortcut, cmd := range commands { 36 | for _, cmdName := range strings.Split(cmdNameWithShortcut, "|") { 37 | exists := cmdName == cmdCandidate 38 | if exists { 39 | return cmd, nil 40 | } 41 | } 42 | } 43 | 44 | return nil, ArgNotFoundError(cmdCandidate) 45 | } 46 | 47 | func isHelp(s string) bool { 48 | return s == "-h" || s == "-help" || s == "h" || s == "help" 49 | } 50 | 51 | func formatCommandDescriptions() string { 52 | var buf bytes.Buffer 53 | w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) 54 | for name, cmd := range commands { 55 | fmt.Fprintf(w, "\t%v\t%v\n", name, cmd.Describe()) 56 | } 57 | w.Flush() 58 | return buf.String() 59 | } 60 | 61 | const usage = `== Web Development 41 == 62 | 63 | This tool is designed to enable live reload for statically hosted web development. 64 | It injects a websocket script in a mirrored version of html pages 65 | and uses the fsnotify (cross-platform 'inotify' wrapper) package to detect filechanges. 66 | On filechanges, the websocket will trigger a reload of the page. 67 | 68 | The 41 (formerly "40", before I got spooked by potential lawyers) is only 69 | to enable rust-repellant properties. 70 | 71 | Commands: 72 | %v` 73 | 74 | func PrintUsage() { 75 | fmt.Printf(usage, formatCommandDescriptions()) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/setup_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/baalimago/wd-41/cmd/serve" 9 | ) 10 | 11 | func Test_Parse(t *testing.T) { 12 | t.Run("it should return command if second argument specifies an existing command", func(t *testing.T) { 13 | want := serve.Command() 14 | got, err := Parse([]string{"/some/cli/path", "serve"}) 15 | if err != nil { 16 | t.Fatalf(": %v", err) 17 | } 18 | if got.Describe() != want.Describe() { 19 | t.Fatalf("expected: %v, got: %v", want, got) 20 | } 21 | }) 22 | 23 | t.Run("it should return command if second argument specifies shortcut of specific command", func(t *testing.T) { 24 | want := serve.Command() 25 | got, err := Parse([]string{"/some/cli/path", "s"}) 26 | if err != nil { 27 | t.Fatalf(": %v", err) 28 | } 29 | if got.Describe() != want.Describe() { 30 | t.Fatalf("expected: %v, got: %v", want, got) 31 | } 32 | }) 33 | 34 | t.Run("it should return error if command doesnt exist", func(t *testing.T) { 35 | badArg := "blheruh" 36 | want := ArgNotFoundError(badArg) 37 | got, gotErr := Parse([]string{"/some/cli/path", badArg}) 38 | if got != nil { 39 | t.Fatalf("expected command to be nil, got: %+v", got) 40 | } 41 | if gotErr != want { 42 | t.Fatalf("expected: %v, got: %v", want, gotErr) 43 | } 44 | }) 45 | 46 | t.Run("it should return NoArgsError on lack of second argument", func(t *testing.T) { 47 | _, gotErr := Parse([]string{"/some/cli/path"}) 48 | if !errors.Is(gotErr, ErrNoArgs) { 49 | t.Fatalf("expected to get HelpfulError, got: %v", gotErr) 50 | } 51 | }) 52 | } 53 | 54 | func TestFormatCommandDescriptions(t *testing.T) { 55 | // Set up mock commands 56 | commands = map[string]Command{ 57 | "testCmd": serve.Command(), 58 | } 59 | 60 | // Call the function we're testing 61 | result := formatCommandDescriptions() 62 | 63 | // Check if the returned string contains the expected command descriptions 64 | expectedSubstring := "testCmd" 65 | if !strings.Contains(result, expectedSubstring) { 66 | t.Errorf("Expected formatted command descriptions to contain '%s', got '%s'", expectedSubstring, result) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /cmd/types.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | ) 9 | 10 | type Command interface { 11 | Setup() error 12 | 13 | // Run and block until context cancel 14 | Run(context.Context) error 15 | 16 | // Help by printing a usage string. Currently not used anywhere. 17 | Help() string 18 | 19 | // Describe the command shortly 20 | Describe() string 21 | 22 | // Flagset which defines the flags for the command 23 | Flagset() *flag.FlagSet 24 | } 25 | 26 | type ( 27 | ArgParser func([]string) (Command, error) 28 | UsagePrinter func() 29 | ) 30 | 31 | type ArgNotFoundError string 32 | 33 | func (e ArgNotFoundError) Error() string { 34 | return fmt.Sprintf("'%v' is not a valid argument\n", string(e)) 35 | } 36 | 37 | var ( 38 | ErrHelpful = errors.New("user needs help") 39 | ErrNoArgs = errors.New("no arguments found") 40 | ) 41 | -------------------------------------------------------------------------------- /cmd/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "runtime/debug" 9 | ) 10 | 11 | // Set with buildflag if built in pipeline and not using go install 12 | var ( 13 | BUILD_VERSION = "" 14 | BUILD_CHECKSUM = "" 15 | ) 16 | 17 | type command struct { 18 | getVersionCmd func() (*debug.BuildInfo, bool) 19 | } 20 | 21 | // Describe the version *command 22 | func (c *command) Describe() string { 23 | return "print the version of wd-41" 24 | } 25 | 26 | // Flagset for version, currently empty 27 | func (c *command) Flagset() *flag.FlagSet { 28 | return flag.NewFlagSet("version", flag.ExitOnError) 29 | } 30 | 31 | // Help by printing out help 32 | func (c *command) Help() string { 33 | return "Print the version of wd-41" 34 | } 35 | 36 | // Run the *command, printing the version using either the debugbuild or tagged version 37 | func (c *command) Run(context.Context) error { 38 | bi, ok := c.getVersionCmd() 39 | if !ok { 40 | return errors.New("failed to read build info") 41 | } 42 | version := bi.Main.Version 43 | checksum := bi.Main.Sum 44 | if version == "" || version == "(devel)" { 45 | version = BUILD_VERSION 46 | } 47 | if checksum == "" { 48 | checksum = BUILD_CHECKSUM 49 | } 50 | fmt.Printf("version: %v, go version: %v, checksum: %v\n", version, bi.GoVersion, checksum) 51 | return nil 52 | } 53 | 54 | // Setup the *command 55 | func (c *command) Setup() error { 56 | c.getVersionCmd = debug.ReadBuildInfo 57 | return nil 58 | } 59 | 60 | func Command() *command { 61 | return &command{} 62 | } 63 | -------------------------------------------------------------------------------- /cmd/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "runtime/debug" 6 | "testing" 7 | 8 | "github.com/baalimago/go_away_boilerplate/pkg/testboil" 9 | ) 10 | 11 | func TestCommand(t *testing.T) { 12 | cmd := Command() 13 | 14 | if cmd == nil { 15 | t.Fatal("Expected command to be non-nil") 16 | } 17 | 18 | if cmd.Describe() != "print the version of wd-41" { 19 | t.Fatalf("Unexpected describe: %v", cmd.Describe()) 20 | } 21 | 22 | fs := cmd.Flagset() 23 | if fs == nil { 24 | t.Fatal("Expected flagset to be non-nil") 25 | } 26 | 27 | help := cmd.Help() 28 | if help != "Print the version of wd-41" { 29 | t.Fatalf("Unexpected help output: %v", help) 30 | } 31 | } 32 | 33 | func TestRun(t *testing.T) { 34 | cmd := Command() 35 | ctx := context.Background() 36 | 37 | t.Run("it should print version info correctly", func(t *testing.T) { 38 | cmd.getVersionCmd = func() (*debug.BuildInfo, bool) { 39 | return &debug.BuildInfo{ 40 | Main: debug.Module{ 41 | Version: "v1.2.3", 42 | Sum: "h1:checksum", 43 | }, 44 | GoVersion: "go1.18", 45 | }, true 46 | } 47 | 48 | // Capture output 49 | got := testboil.CaptureStdout(t, func(t *testing.T) { 50 | err := cmd.Run(ctx) 51 | if err != nil { 52 | t.Fatalf("Run failed: %v", err) 53 | } 54 | }) 55 | 56 | expected := "version: v1.2.3, go version: go1.18, checksum: h1:checksum\n" 57 | if got != expected { 58 | t.Fatalf("Expected output %s, got %s", expected, got) 59 | } 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/baalimago/wd-41 2 | 3 | go 1.22.2 4 | 5 | require github.com/baalimago/go_away_boilerplate v1.3.32 6 | 7 | require ( 8 | github.com/fsnotify/fsnotify v1.7.0 9 | golang.org/x/net v0.28.0 10 | golang.org/x/sys v0.23.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/baalimago/go_away_boilerplate v1.3.11 h1:dM3Hckn55zSb6D9N03+TTSUxFJ6207GsCiS6n8vVZCs= 2 | github.com/baalimago/go_away_boilerplate v1.3.11/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 3 | github.com/baalimago/go_away_boilerplate v1.3.12 h1:NrlUcxDAZbSn4iXCLBywcHsx8dbjXjJmmZoOXQg0y6A= 4 | github.com/baalimago/go_away_boilerplate v1.3.12/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 5 | github.com/baalimago/go_away_boilerplate v1.3.13 h1:aQ3Q73Nr7pBHY90gFQAhFdVkKvysmoTGHrhJu3OhxPE= 6 | github.com/baalimago/go_away_boilerplate v1.3.13/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 7 | github.com/baalimago/go_away_boilerplate v1.3.14 h1:MkCWZ0oxeSolA8J/Oin6D62I3cA8syNzsys8yarUBI4= 8 | github.com/baalimago/go_away_boilerplate v1.3.14/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 9 | github.com/baalimago/go_away_boilerplate v1.3.15 h1:iOn7G59bos8k+TY0MFrQvo+j+e0hV6XobppiD/JO0sc= 10 | github.com/baalimago/go_away_boilerplate v1.3.15/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 11 | github.com/baalimago/go_away_boilerplate v1.3.16 h1:RHQ2MDzkzQMbAse2nUzWoDeRT12TXth9n6qvQJaQrlY= 12 | github.com/baalimago/go_away_boilerplate v1.3.16/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 13 | github.com/baalimago/go_away_boilerplate v1.3.17 h1:sWOGEB9nXJbyk9fsxHBiTnkRB+Q1+hClvnKUtnQkvRU= 14 | github.com/baalimago/go_away_boilerplate v1.3.17/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 15 | github.com/baalimago/go_away_boilerplate v1.3.18 h1:5rNAAokKGUedg68Jo/O0sv0MErx95oyH6eLivvgpP9c= 16 | github.com/baalimago/go_away_boilerplate v1.3.18/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 17 | github.com/baalimago/go_away_boilerplate v1.3.19 h1:Lwfny7hf+OISiJgGFy8Ly+djnB1Camxn8jgSWJr4HOM= 18 | github.com/baalimago/go_away_boilerplate v1.3.19/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 19 | github.com/baalimago/go_away_boilerplate v1.3.20 h1:M9g5GRanpvxGXyAhBnWUQzqiQU4lBplQZ1SLT5pq/TE= 20 | github.com/baalimago/go_away_boilerplate v1.3.20/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 21 | github.com/baalimago/go_away_boilerplate v1.3.21 h1:viKPJcke1BnsJOJpuJk/d3UFvKUtKHmnRXnrHztp5Kc= 22 | github.com/baalimago/go_away_boilerplate v1.3.21/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 23 | github.com/baalimago/go_away_boilerplate v1.3.22 h1:l8PI+rP82JVSRzLqk0T4aAaM8Rv3hYJcE48VFaFv2oE= 24 | github.com/baalimago/go_away_boilerplate v1.3.22/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 25 | github.com/baalimago/go_away_boilerplate v1.3.23 h1:r1wDVLdNBP4J4MEvIFN7296gXBrlBA11+a+VU9CCjyo= 26 | github.com/baalimago/go_away_boilerplate v1.3.23/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 27 | github.com/baalimago/go_away_boilerplate v1.3.24 h1:0l/+1GPSVcnNj+jknoIu6Ww0AcbrPFyjeaN7ICYCGoo= 28 | github.com/baalimago/go_away_boilerplate v1.3.24/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 29 | github.com/baalimago/go_away_boilerplate v1.3.25 h1:8w0sQMJftyX3r0KImKiT9M0Oz4RzNNpc3m/ARJ4lPTE= 30 | github.com/baalimago/go_away_boilerplate v1.3.25/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 31 | github.com/baalimago/go_away_boilerplate v1.3.26 h1:1L8Yki53/VfHVLOBs4FEp+2hfqqDHwBNiBtkr1bHhjg= 32 | github.com/baalimago/go_away_boilerplate v1.3.26/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 33 | github.com/baalimago/go_away_boilerplate v1.3.27 h1:Cr8rb99s2SKr/FiTfAQbB8xwzE06m4bSaWeC0/cIN0A= 34 | github.com/baalimago/go_away_boilerplate v1.3.27/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 35 | github.com/baalimago/go_away_boilerplate v1.3.28 h1:PefDSmL0XzpiliLfb0lrjHtwDUIdbCQNKwlPHhi24Gg= 36 | github.com/baalimago/go_away_boilerplate v1.3.28/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 37 | github.com/baalimago/go_away_boilerplate v1.3.29 h1:+CyeSxOa3BsnlCQZV20ueVYuY3bd2XFPLy27Lzu3djk= 38 | github.com/baalimago/go_away_boilerplate v1.3.29/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 39 | github.com/baalimago/go_away_boilerplate v1.3.30 h1:kncoPdn8dZSp9eDa0oW9V0MCxcgSsRaiNbBahkJ1fVI= 40 | github.com/baalimago/go_away_boilerplate v1.3.30/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 41 | github.com/baalimago/go_away_boilerplate v1.3.32 h1:4+TBHtLpUVo1oCq1/v8wA8k8GdZVDBLY1+NZf2vcXA4= 42 | github.com/baalimago/go_away_boilerplate v1.3.32/go.mod h1:2O+zQ0Zm8vPD5SeccFFlgyf3AnYWQSHAut/ecPMmRdU= 43 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 44 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 45 | golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 46 | golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 47 | golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= 48 | golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 49 | -------------------------------------------------------------------------------- /internal/wsinject/delta_streamer.go: -------------------------------------------------------------------------------- 1 | package wsinject 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | 7 | "github.com/baalimago/go_away_boilerplate/pkg/ancli" 8 | "github.com/baalimago/go_away_boilerplate/pkg/threadsafe" 9 | "golang.org/x/net/websocket" 10 | ) 11 | 12 | // WsHandler sends page reload notifications to the connected websocket 13 | func (fs *Fileserver) WsHandler(ws *websocket.Conn) { 14 | reloadChan := make(chan string) 15 | killChan := make(chan struct{}) 16 | name := "ws-" + fmt.Sprintf("%v", rand.Int()) 17 | 18 | go func() { 19 | ancli.PrintfOK("new websocket connection: '%v'", ws.Config().Origin) 20 | for { 21 | pageToReload, ok := <-reloadChan 22 | if !ok { 23 | killChan <- struct{}{} 24 | } 25 | err := websocket.Message.Send(ws, pageToReload) 26 | if err != nil { 27 | // Exit on error 28 | ancli.PrintfErr("ws: failed to send message via ws: %v", err) 29 | killChan <- struct{}{} 30 | } 31 | } 32 | }() 33 | 34 | ancli.PrintOK("Listening to file changes on pageReloadChan") 35 | fs.registerWs(name, reloadChan) 36 | <-killChan 37 | ancli.PrintOK("websocket disconnected") 38 | fs.deregisterWs(name) 39 | err := ws.WriteClose(1005) 40 | if err != nil { 41 | ancli.PrintfErr("ws-listener: '%v' got err when writeclosing: %v", name, err) 42 | } 43 | err = ws.Close() 44 | if err != nil { 45 | ancli.PrintfErr("ws-listener: '%v' got err when closing: %v", name, err) 46 | } 47 | } 48 | 49 | func (fs *Fileserver) registerWs(name string, c chan string) { 50 | if !threadsafe.Read(fs.wsDispatcherStartedMu, fs.wsDispatcherStarted) { 51 | go fs.wsDispatcherStart() 52 | threadsafe.Write(fs.wsDispatcherStartedMu, true, fs.wsDispatcherStarted) 53 | } 54 | ancli.PrintfNotice("registering: '%v'", name) 55 | fs.wsDispatcher.Store(name, c) 56 | } 57 | 58 | func (fs *Fileserver) deregisterWs(name string) { 59 | fs.wsDispatcher.Delete(name) 60 | } 61 | 62 | func (fs *Fileserver) wsDispatcherStart() { 63 | for { 64 | pageToReload, ok := <-fs.pageReloadChan 65 | if !ok { 66 | ancli.PrintNotice("stopping wsDispatcher") 67 | fs.wsDispatcher.Range(func(key, value any) bool { 68 | ancli.PrintfNotice("sending to: '%v'", key) 69 | wsWriterChan := value.(chan string) 70 | // Close chan to stop the wsRoutine 71 | close(wsWriterChan) 72 | return true 73 | }) 74 | return 75 | } 76 | ancli.PrintfNotice("got update: '%v'", pageToReload) 77 | fs.wsDispatcher.Range(func(key, value any) bool { 78 | ancli.PrintfNotice("sending to: '%v'", key) 79 | wsWriterChan := value.(chan string) 80 | wsWriterChan <- pageToReload 81 | return true 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /internal/wsinject/delta_streamer.ws.go: -------------------------------------------------------------------------------- 1 | package wsinject 2 | 3 | const deltaStreamerSourceCode = `/** 4 | * This file has been injected by the wd-41 web development 5 | * hot reload tool. 6 | */ 7 | 8 | function startWebsocket() { 9 | // Check if the WebSocket object is available in the current context 10 | if (typeof WebSocket !== 'function') { 11 | console.error('WebSocket is not supported by this browser.'); 12 | return; 13 | } 14 | 15 | // Establish a connection with the WebSocket server 16 | const socket = new WebSocket('ws%v://localhost:%v%v'); 17 | 18 | // Event handler for when the WebSocket connection is established 19 | socket.addEventListener('open', function (event) { 20 | console.log('Connected to the WebSocket server'); 21 | }); 22 | 23 | // Event handler for when a message is received from the server 24 | socket.addEventListener('message', function (event) { 25 | console.log('Message from server:', event.data); 26 | let fileName = window.location.pathname.split('/').pop(); 27 | if (fileName === "") { 28 | fileName = "/index.html" 29 | } else { 30 | fileName = "/" + fileName 31 | } 32 | // Reload page if it's detected that the current page has been altered 33 | if (event.data === fileName || 34 | // Always reload on js and css files since its difficult to know where these are used 35 | event.data.includes(".js") || 36 | event.data.includes(".css") || 37 | // This funny-looking comparison is set using string interpolation from the -forceReload flag 38 | // when writing this script 39 | % v === true 40 | ) { 41 | location.reload(); 42 | } 43 | }); 44 | 45 | // Event handler for when the WebSocket connection is closed 46 | socket.addEventListener('close', function (event) { 47 | console.log('Disconnected from the WebSocket server'); 48 | // The socket is dead. Let's make a new one (and keep trying until wd-41 backend 49 | // process is back up again) 50 | setTimeout(startWebsocket, 3000) 51 | }); 52 | 53 | // Event handler for when an error occurs with the WebSocket connection 54 | socket.addEventListener('error', function (event) { 55 | console.error('WebSocket error:', event); 56 | console.error(event.message); 57 | }); 58 | } 59 | 60 | startWebsocket();` 61 | -------------------------------------------------------------------------------- /internal/wsinject/delta_streamer_test.go: -------------------------------------------------------------------------------- 1 | package wsinject 2 | 3 | import ( 4 | "fmt" 5 | "net/http/httptest" 6 | "strings" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/baalimago/go_away_boilerplate/pkg/ancli" 12 | "golang.org/x/net/websocket" 13 | ) 14 | 15 | func TestWsHandler(t *testing.T) { 16 | ancli.Newline = true 17 | setup := func(t *testing.T) (*Fileserver, *websocket.Config, *httptest.Server) { 18 | t.Helper() 19 | started := false 20 | fs := &Fileserver{ 21 | pageReloadChan: make(chan string), 22 | wsDispatcher: sync.Map{}, 23 | wsDispatcherStarted: &started, 24 | wsDispatcherStartedMu: &sync.Mutex{}, 25 | } 26 | 27 | server := httptest.NewServer(websocket.Handler(fs.WsHandler)) 28 | 29 | port := strings.Replace(server.URL, "http://127.0.0.1:", "", -1) 30 | wsConfig, err := websocket.NewConfig(fmt.Sprintf("ws://localhost:%v", port), "ws://localhost/") 31 | if err != nil { 32 | t.Fatalf("Failed to create WebSocket config: %v", err) 33 | } 34 | 35 | return fs, wsConfig, server 36 | } 37 | 38 | t.Run("it should send messages posted on pageReloadChan", func(t *testing.T) { 39 | fs, wsConfig, testServer := setup(t) 40 | 41 | ws, err := websocket.DialConfig(wsConfig) 42 | if err != nil { 43 | t.Fatalf("Failed to connect to WebSocket: %v", err) 44 | } 45 | t.Cleanup(func() { 46 | testServer.Close() 47 | ws.Close() 48 | }) 49 | 50 | go func() { 51 | fs.pageReloadChan <- "test message" 52 | }() 53 | 54 | var msg string 55 | err = websocket.Message.Receive(ws, &msg) 56 | if err != nil { 57 | t.Fatalf("Failed to receive message: %v", err) 58 | } 59 | 60 | if msg != "test message" { 61 | t.Fatalf("Expected 'test message', got: %v", msg) 62 | } 63 | 64 | close(fs.pageReloadChan) 65 | select { 66 | case <-time.After(time.Second): 67 | t.Fatal("Expected the WebSocket to be closed") 68 | case <-fs.pageReloadChan: 69 | } 70 | }) 71 | 72 | t.Run("it should handle multiple connections at once", func(t *testing.T) { 73 | fs, wsConfig, testServer := setup(t) 74 | 75 | mockWebClient0, err := websocket.DialConfig(wsConfig) 76 | if err != nil { 77 | t.Fatalf("Failed to connect to WebSocket: %v", err) 78 | } 79 | 80 | mockWebClient1, err := websocket.DialConfig(wsConfig) 81 | if err != nil { 82 | t.Fatalf("Failed to connect to WebSocket: %v", err) 83 | } 84 | 85 | t.Cleanup(func() { 86 | mockWebClient0.Close() 87 | mockWebClient1.Close() 88 | testServer.Close() 89 | }) 90 | 91 | mu := &sync.Mutex{} 92 | go func() { 93 | mu.Lock() 94 | defer mu.Unlock() 95 | fs.pageReloadChan <- "test message" 96 | }() 97 | 98 | gotMsgChan := make(chan string) 99 | for _, wsClient := range []*websocket.Conn{mockWebClient0, mockWebClient1} { 100 | go func(wsClient *websocket.Conn) { 101 | for { 102 | var msg string 103 | websocket.Message.Receive(wsClient, &msg) 104 | gotMsgChan <- msg 105 | } 106 | }(wsClient) 107 | } 108 | want := 0 109 | for want != 2 { 110 | select { 111 | case <-time.After(time.Second): 112 | t.Fatal("failed to receive data from websocket") 113 | case got := <-gotMsgChan: 114 | want += 1 115 | t.Logf("got message from mocked ws client: %v", got) 116 | } 117 | } 118 | 119 | mu.Lock() 120 | close(fs.pageReloadChan) 121 | mu.Unlock() 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /internal/wsinject/wsinject.go: -------------------------------------------------------------------------------- 1 | package wsinject 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io/fs" 9 | "log" 10 | "net/http" 11 | "os" 12 | "path" 13 | "path/filepath" 14 | "strings" 15 | "sync" 16 | 17 | "github.com/baalimago/go_away_boilerplate/pkg/ancli" 18 | "github.com/fsnotify/fsnotify" 19 | ) 20 | 21 | type Fileserver struct { 22 | masterPath string 23 | mirrorPath string 24 | forceReload bool 25 | expectTLS bool 26 | wsPort int 27 | wsPath string 28 | watcher *fsnotify.Watcher 29 | 30 | pageReloadChan chan string 31 | wsDispatcher sync.Map 32 | wsDispatcherStarted *bool 33 | wsDispatcherStartedMu *sync.Mutex 34 | } 35 | 36 | var ErrNoHeaderTagFound = errors.New("no header tag found") 37 | 38 | const deltaStreamer = ` 39 | ` 40 | 41 | func NewFileServer(wsPort int, wsPath string, forceReload, expectTLS bool) *Fileserver { 42 | mirrorDir, err := os.MkdirTemp("", "wd-41_*") 43 | if err != nil { 44 | panic(err) 45 | } 46 | started := false 47 | return &Fileserver{ 48 | mirrorPath: mirrorDir, 49 | wsPort: wsPort, 50 | wsPath: wsPath, 51 | expectTLS: expectTLS, 52 | forceReload: forceReload, 53 | pageReloadChan: make(chan string), 54 | wsDispatcher: sync.Map{}, 55 | wsDispatcherStarted: &started, 56 | wsDispatcherStartedMu: &sync.Mutex{}, 57 | } 58 | } 59 | 60 | func (fs *Fileserver) mirrorFile(origPath string) error { 61 | relativePath := strings.Replace(origPath, fs.masterPath, "", -1) 62 | fileB, err := os.ReadFile(origPath) 63 | if err != nil { 64 | return fmt.Errorf("failed to read file on path: '%v', err: %v", origPath, err) 65 | } 66 | injected, injectedBytes, err := injectWebsocketScript(fileB) 67 | if err != nil { 68 | return fmt.Errorf("failed to inject websocket script: %v", err) 69 | } 70 | if injected { 71 | ancli.PrintfNotice("injected delta-streamer script loading tag in: '%v'", origPath) 72 | } 73 | mirroredPath := path.Join(fs.mirrorPath, relativePath) 74 | relativePathDir := path.Dir(mirroredPath) 75 | err = os.MkdirAll(relativePathDir, 0o755) 76 | if err != nil { 77 | return fmt.Errorf("failed to create relative dir: '%v', error: %v", relativePathDir, err) 78 | } 79 | err = os.WriteFile(mirroredPath, injectedBytes, 0o755) 80 | if err != nil { 81 | return fmt.Errorf("failed to write mirrored file: %w", err) 82 | } 83 | return nil 84 | } 85 | 86 | func (fs *Fileserver) mirrorMaker(p string, info os.DirEntry, err error) error { 87 | if err != nil { 88 | return err 89 | } 90 | if info.IsDir() { 91 | err = fs.watcher.Add(p) 92 | if err != nil { 93 | return fmt.Errorf("failed to add recursive path: %v", err) 94 | } 95 | return nil 96 | } 97 | 98 | return fs.mirrorFile(p) 99 | } 100 | 101 | func (fs *Fileserver) writeDeltaStreamerScript() error { 102 | tlsS := "" 103 | if fs.expectTLS { 104 | tlsS = "s" 105 | } 106 | err := os.WriteFile( 107 | path.Join(fs.mirrorPath, "delta-streamer.js"), 108 | []byte(fmt.Sprintf(deltaStreamerSourceCode, tlsS, fs.wsPort, fs.wsPath, fs.forceReload)), 109 | 0o755) 110 | if err != nil { 111 | return fmt.Errorf("failed to write delta-streamer.js: %w", err) 112 | } 113 | return nil 114 | } 115 | 116 | func (fs *Fileserver) Setup(pathToMaster string) (string, error) { 117 | ancli.PrintfNotice("mirroring root: '%v'", pathToMaster) 118 | fs.masterPath = pathToMaster 119 | watcher, err := fsnotify.NewWatcher() 120 | fs.watcher = watcher 121 | if err != nil { 122 | return "", fmt.Errorf("failed to create fsnotify watcher: %w", err) 123 | } 124 | err = wsInjectMaster(pathToMaster, fs.mirrorMaker) 125 | if err != nil { 126 | return "", fmt.Errorf("failed to create websocket injected mirror: %v", err) 127 | } 128 | err = fs.writeDeltaStreamerScript() 129 | if err != nil { 130 | return "", fmt.Errorf("failed to write delta streamer file: %w", err) 131 | } 132 | return fs.mirrorPath, nil 133 | } 134 | 135 | // Start listening to file events, update mirror and stream notifications 136 | // on which files to update 137 | func (fs *Fileserver) Start(ctx context.Context) error { 138 | for { 139 | select { 140 | case <-ctx.Done(): 141 | return nil 142 | case fsEv, ok := <-fs.watcher.Events: 143 | if !ok { 144 | return errors.New("fsnotify watcher event channel closed") 145 | } 146 | fs.handleFileEvent(fsEv) 147 | case fsErr, ok := <-fs.watcher.Errors: 148 | if !ok { 149 | return errors.New("fsnotify watcher error channel closed") 150 | } 151 | return fsErr 152 | } 153 | } 154 | } 155 | 156 | func (fs *Fileserver) notifyPageUpdate(fileName string) { 157 | // Make filename relative idempotently 158 | fs.pageReloadChan <- strings.Replace(fileName, fs.masterPath, "", -1) 159 | } 160 | 161 | func (fs *Fileserver) handleFileEvent(fsEv fsnotify.Event) { 162 | if fsEv.Has(fsnotify.Write) { 163 | ancli.PrintfNotice("noticed file write in orig file: '%v'", fsEv.Name) 164 | fs.mirrorFile(fsEv.Name) 165 | fs.notifyPageUpdate(fsEv.Name) 166 | } 167 | } 168 | 169 | func wsInjectMaster(root string, do func(path string, d fs.DirEntry, err error) error) error { 170 | err := filepath.WalkDir(root, do) 171 | if err != nil { 172 | log.Fatalf("Error walking the path %q: %v\n", root, err) 173 | } 174 | return nil 175 | } 176 | 177 | func injectScript(html []byte, scriptTag string) ([]byte, error) { 178 | htmlStr := string(html) 179 | 180 | // Find the location of the closing `` tag 181 | idx := strings.Index(htmlStr, "") 182 | if idx == -1 { 183 | return html, ErrNoHeaderTagFound 184 | } 185 | 186 | var buf bytes.Buffer 187 | 188 | // Write the HTML up to the closing `` tag 189 | _, err := buf.WriteString(htmlStr[:idx]) 190 | if err != nil { 191 | return nil, fmt.Errorf("failed to write pre: %w", err) 192 | } 193 | 194 | _, err = buf.WriteString(scriptTag) 195 | if err != nil { 196 | return nil, fmt.Errorf("failed to write script tag: %w", err) 197 | } 198 | 199 | _, err = buf.WriteString(htmlStr[idx:]) 200 | if err != nil { 201 | return nil, fmt.Errorf("failed to write post: %w", err) 202 | } 203 | 204 | return buf.Bytes(), nil 205 | } 206 | 207 | func injectWebsocketScript(b []byte) (bool, []byte, error) { 208 | contentType := http.DetectContentType(b) 209 | injected := false 210 | // Only act on html files 211 | if !strings.Contains(contentType, "text/html") { 212 | return injected, b, nil 213 | } 214 | b, err := injectScript(b, deltaStreamer) 215 | injected = true 216 | if err != nil { 217 | if !errors.Is(err, ErrNoHeaderTagFound) { 218 | return injected, nil, fmt.Errorf("failed to inject script tag: %w", err) 219 | } else { 220 | injected = false 221 | } 222 | } 223 | 224 | return injected, b, nil 225 | } 226 | -------------------------------------------------------------------------------- /internal/wsinject/wsinject_test.go: -------------------------------------------------------------------------------- 1 | package wsinject 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "slices" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/baalimago/go_away_boilerplate/pkg/ancli" 15 | "github.com/baalimago/go_away_boilerplate/pkg/testboil" 16 | ) 17 | 18 | func Test_walkDir(t *testing.T) { 19 | t.Run("it should visit every file ", func(t *testing.T) { 20 | var got []string 21 | want := []string{"t0", "t1", "t2"} 22 | tmpDir := t.TempDir() 23 | os.WriteFile(path.Join(tmpDir, "t0"), []byte("t0"), 0o644) 24 | os.WriteFile(path.Join(tmpDir, "t1"), []byte("t1"), 0o644) 25 | nestedDir, err := os.MkdirTemp(tmpDir, "dir0_*") 26 | if err != nil { 27 | t.Fatalf("failed to create temp dir: %v", err) 28 | } 29 | os.WriteFile(path.Join(nestedDir, "t2"), []byte("t2"), 0o644) 30 | 31 | wsInjectMaster(tmpDir, func(path string, d os.DirEntry, err error) error { 32 | if err != nil { 33 | t.Fatalf("got err during traversal: %v", err) 34 | } 35 | if d.IsDir() { 36 | return nil 37 | } 38 | b, err := os.ReadFile(path) 39 | if err != nil { 40 | t.Fatalf("failed to read file: %v", err) 41 | } 42 | got = append(got, string(b)) 43 | return nil 44 | }) 45 | 46 | slices.Sort(got) 47 | slices.Sort(want) 48 | if !slices.Equal(got, want) { 49 | t.Fatalf("expected: %v, got: %v", want, got) 50 | } 51 | }) 52 | } 53 | 54 | const mockHtml = ` 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ` 66 | 67 | func Test_Setup(t *testing.T) { 68 | tmpDir := t.TempDir() 69 | ancli.Newline = true 70 | fileName := "t0.html" 71 | os.WriteFile(path.Join(tmpDir, fileName), []byte(mockHtml), 0o777) 72 | nestedDir := path.Join(tmpDir, "nested") 73 | err := os.MkdirAll(nestedDir, 0o777) 74 | if err != nil { 75 | t.Fatalf("failed to create temp dir: %v", err) 76 | } 77 | nestedFile := path.Join(nestedDir, "nested.html") 78 | os.WriteFile(nestedFile, []byte(mockHtml), 0o777) 79 | fs := NewFileServer(8080, "/delta-streamer-ws.js", false, false) 80 | _, err = fs.Setup(tmpDir) 81 | if err != nil { 82 | t.Fatalf("failed to setup: %v", err) 83 | } 84 | checkIfInjected := func(t *testing.T, filePath string) { 85 | b, err := os.ReadFile(filePath) 86 | if err != nil { 87 | t.Fatalf("failed to read mirrored file: %v", err) 88 | } 89 | if !strings.Contains(string(b), "delta-streamer.js") { 90 | t.Fatalf("expected mirrored file: '%v' to have been injected with content-streamer.js", filePath) 91 | } 92 | } 93 | t.Run("it should inject delta-streamer.js source tag", func(t *testing.T) { 94 | mirrorFilePath := path.Join(fs.mirrorPath, fileName) 95 | checkIfInjected(t, mirrorFilePath) 96 | }) 97 | 98 | t.Run("it should inject delta-streamer.js souce tag to nested files", func(t *testing.T) { 99 | mirrorFilePath := path.Join(fs.mirrorPath, "nested", "nested.html") 100 | checkIfInjected(t, mirrorFilePath) 101 | }) 102 | 103 | checkIfDeltaStreamerExists := func(t *testing.T, filePath string) { 104 | t.Helper() 105 | b, err := os.ReadFile(filePath) 106 | if err != nil { 107 | t.Fatalf("failed to find delta-streamer.js: %v", err) 108 | } 109 | // Whatever happens in the delta streamre source code, it should mention wd-41 110 | if !strings.Contains(string(b), "wd-41") { 111 | t.Fatal("expected delta-streamer.js file to conain string 'wd-41'") 112 | } 113 | } 114 | t.Run("it should write the delta streamer file to root of mirror", func(t *testing.T) { 115 | mirrorFilePath := path.Join(fs.mirrorPath, "delta-streamer.js") 116 | checkIfDeltaStreamerExists(t, mirrorFilePath) 117 | }) 118 | } 119 | 120 | type testFileSystem struct { 121 | root string 122 | rootDirFilePaths []string 123 | nestedDir string 124 | } 125 | 126 | func (tfs *testFileSystem) addRootFile(t *testing.T, suffix string) string { 127 | t.Helper() 128 | fileName := fmt.Sprintf("file_%v%v", len(tfs.rootDirFilePaths), suffix) 129 | path := path.Join(tfs.root, fileName) 130 | err := os.WriteFile(path, []byte(mockHtml), 0o777) 131 | if err != nil { 132 | t.Fatalf("failed to write root file: %v", err) 133 | } 134 | tfs.rootDirFilePaths = append(tfs.rootDirFilePaths, path) 135 | return path 136 | } 137 | 138 | func Test_Start(t *testing.T) { 139 | setup := func(t *testing.T) (*Fileserver, testFileSystem) { 140 | t.Helper() 141 | tmpDir := t.TempDir() 142 | nestedDir := path.Join(tmpDir, "nested") 143 | err := os.MkdirAll(nestedDir, 0o777) 144 | if err != nil { 145 | t.Fatalf("failed to create temp dir: %v", err) 146 | } 147 | return NewFileServer(8080, "/delta-streamer-ws.js", false, false), testFileSystem{ 148 | root: tmpDir, 149 | nestedDir: nestedDir, 150 | } 151 | } 152 | 153 | t.Run("it should break on context cancel", func(t *testing.T) { 154 | fs, _ := setup(t) 155 | _, err := fs.Setup(t.TempDir()) 156 | if err != nil { 157 | t.Fatalf("failed ot setup test fileserver: %v", err) 158 | } 159 | testboil.ReturnsOnContextCancel(t, func(ctx context.Context) { 160 | fs.Start(ctx) 161 | }, time.Second) 162 | }) 163 | 164 | t.Run("file changes", func(t *testing.T) { 165 | setupReadyFs := func(t *testing.T) (testFileSystem, chan error, chan string, context.Context) { 166 | t.Helper() 167 | fs, testFileSystem := setup(t) 168 | testFileSystem.addRootFile(t, "") 169 | fs.Setup(testFileSystem.root) 170 | refreshChan := make(chan string) 171 | fs.registerWs("mock", refreshChan) 172 | timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second) 173 | t.Cleanup(cancel) 174 | earlyFail := make(chan error, 1) 175 | awaitFsStart := make(chan struct{}) 176 | go func() { 177 | close(awaitFsStart) 178 | err := fs.Start(timeoutCtx) 179 | if err != nil { 180 | earlyFail <- err 181 | } 182 | }() 183 | 184 | <-awaitFsStart 185 | // Give the Start a moment to actually start, not just the routine 186 | time.Sleep(time.Millisecond) 187 | return testFileSystem, earlyFail, refreshChan, timeoutCtx 188 | } 189 | 190 | t.Run("it should send a reload event on file changes", func(t *testing.T) { 191 | testFileSystem, earlyFail, refreshChan, timeoutCtx := setupReadyFs(t) 192 | testFile := testFileSystem.rootDirFilePaths[0] 193 | os.WriteFile(testFile, []byte("changes!"), 0o755) 194 | 195 | select { 196 | case err := <-earlyFail: 197 | t.Fatalf("start failed: %v", err) 198 | case got := <-refreshChan: 199 | testboil.FailTestIfDiff(t, got, "/"+filepath.Base(testFile)) 200 | case <-timeoutCtx.Done(): 201 | t.Fatal("failed to receive refresh within time") 202 | } 203 | }) 204 | 205 | t.Run("it should send a reload event on file additions", func(t *testing.T) { 206 | testFileSystem, earlyFail, refreshChan, timeoutCtx := setupReadyFs(t) 207 | testFile := testFileSystem.addRootFile(t, "") 208 | select { 209 | case err := <-earlyFail: 210 | t.Fatalf("start failed: %v", err) 211 | case got := <-refreshChan: 212 | testboil.FailTestIfDiff(t, got, "/"+filepath.Base(testFile)) 213 | case <-timeoutCtx.Done(): 214 | t.Fatal("failed to receive refresh within time") 215 | } 216 | }) 217 | }) 218 | } 219 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/baalimago/go_away_boilerplate/pkg/ancli" 10 | "github.com/baalimago/go_away_boilerplate/pkg/shutdown" 11 | "github.com/baalimago/wd-41/cmd" 12 | ) 13 | 14 | func printHelp(command cmd.Command, err error, printUsage cmd.UsagePrinter) int { 15 | var notValidArg cmd.ArgNotFoundError 16 | if errors.As(err, ¬ValidArg) { 17 | ancli.PrintErr(err.Error()) 18 | printUsage() 19 | } else if errors.Is(err, cmd.ErrNoArgs) { 20 | printUsage() 21 | } else if errors.Is(err, cmd.ErrHelpful) { 22 | if command != nil { 23 | fmt.Println(command.Help()) 24 | } else { 25 | printUsage() 26 | } 27 | return 0 28 | } else { 29 | ancli.PrintfErr("unknown error: %v", err.Error()) 30 | } 31 | return 1 32 | } 33 | 34 | func run(ctx context.Context, args []string, parseArgs cmd.ArgParser) int { 35 | command, err := parseArgs(args) 36 | if err != nil { 37 | return printHelp(command, err, cmd.PrintUsage) 38 | } 39 | fs := command.Flagset() 40 | var cmdArgs []string 41 | if len(args) > 2 { 42 | cmdArgs = args[2:] 43 | } 44 | err = fs.Parse(cmdArgs) 45 | if err != nil { 46 | ancli.PrintfErr("failed to parse flagset: %v", err.Error()) 47 | return 1 48 | } 49 | 50 | err = command.Setup() 51 | if err != nil { 52 | ancli.PrintfErr("failed to setup command: %v", err.Error()) 53 | return 1 54 | } 55 | 56 | err = command.Run(ctx) 57 | if err != nil { 58 | ancli.PrintfErr("failed to run %v", err.Error()) 59 | return 1 60 | } 61 | return 0 62 | } 63 | 64 | func main() { 65 | ancli.Newline = true 66 | ancli.SlogIt = true 67 | ancli.SetupSlog() 68 | ctx, cancel := context.WithCancel(context.Background()) 69 | exitCodeChan := make(chan int, 1) 70 | go func() { 71 | exitCodeChan <- run(ctx, os.Args, cmd.Parse) 72 | cancel() 73 | }() 74 | shutdown.MonitorV2(ctx, cancel) 75 | os.Exit(<-exitCodeChan) 76 | } 77 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/baalimago/go_away_boilerplate/pkg/testboil" 11 | "github.com/baalimago/wd-41/cmd" 12 | ) 13 | 14 | type MockCommand struct { 15 | runFunc func(context.Context) error 16 | helpFunc func() string 17 | describeFunc func() string 18 | } 19 | 20 | func (m MockCommand) Run(ctx context.Context) error { 21 | return m.runFunc(ctx) 22 | } 23 | 24 | func (m MockCommand) Help() string { 25 | return m.helpFunc() 26 | } 27 | 28 | func (m MockCommand) Describe() string { 29 | return m.describeFunc() 30 | } 31 | 32 | func (m MockCommand) Setup() error { 33 | return nil 34 | } 35 | 36 | func (m MockCommand) Flagset() *flag.FlagSet { 37 | return flag.NewFlagSet("test", flag.ContinueOnError) 38 | } 39 | 40 | func Test_printHelp_ExitCodes(t *testing.T) { 41 | mCmd := MockCommand{ 42 | helpFunc: func() string { return "Help message" }, 43 | describeFunc: func() string { return "Describe message" }, 44 | } 45 | tests := []struct { 46 | name string 47 | command cmd.Command 48 | err error 49 | expected int 50 | }{ 51 | { 52 | name: "It should exit with code 1 on ArgNotFoundError", 53 | command: mCmd, 54 | err: cmd.ArgNotFoundError("test"), 55 | expected: 1, 56 | }, 57 | { 58 | name: "it should exit with code 0 on HelpfulError", 59 | command: mCmd, 60 | err: cmd.ErrHelpful, 61 | expected: 0, 62 | }, 63 | { 64 | name: "it should exit with code 1 on unknown errors", 65 | command: mCmd, 66 | err: errors.New("unknown error"), 67 | expected: 1, 68 | }, 69 | } 70 | 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | result := printHelp(tt.command, tt.err, func() {}) 74 | if result != tt.expected { 75 | t.Errorf("printHelp() = %v, want %v", result, tt.expected) 76 | } 77 | }) 78 | } 79 | } 80 | 81 | func Test_printHelp_output(t *testing.T) { 82 | t.Run("it should print cmd help on cmd.HelpfulError", func(t *testing.T) { 83 | want := "hello here is helpful message" 84 | mCmd := MockCommand{ 85 | helpFunc: func() string { return want }, 86 | } 87 | got := testboil.CaptureStdout(t, func(t *testing.T) { 88 | t.Helper() 89 | printHelp(mCmd, cmd.ErrHelpful, func() {}) 90 | }) 91 | // add the printline since we actually want a newline at end 92 | want = want + "\n" 93 | if got != want { 94 | t.Fatalf("expected: '%v', got: '%v'", want, got) 95 | } 96 | }) 97 | 98 | t.Run("it should print error and usage on invalid argument", func(t *testing.T) { 99 | wantErr := "here is an error message" 100 | wantCode := 1 101 | usageHasBenePrinted := false 102 | mockUsagePrinter := func() { 103 | usageHasBenePrinted = true 104 | } 105 | gotCode := 0 106 | gotStdErr := testboil.CaptureStderr(t, func(t *testing.T) { 107 | t.Helper() 108 | gotCode = printHelp(MockCommand{}, cmd.ArgNotFoundError(wantErr), mockUsagePrinter) 109 | }) 110 | 111 | if gotCode != wantCode { 112 | t.Fatalf("expected: %v, got: %v", wantCode, gotCode) 113 | } 114 | if !usageHasBenePrinted { 115 | t.Fatal("expected usage to have been printed") 116 | } 117 | if !strings.Contains(gotStdErr, wantErr) { 118 | t.Fatalf("expected stdout to contain: '%v', got out: '%v'", wantErr, gotStdErr) 119 | } 120 | }) 121 | } 122 | 123 | func Test_Run_ExitCodes(t *testing.T) { 124 | tests := []struct { 125 | name string 126 | args []string 127 | argParser cmd.ArgParser 128 | expected int 129 | }{ 130 | { 131 | name: "on invalid arg, it should return exit code 1", 132 | args: []string{"invalid"}, 133 | argParser: func(s []string) (cmd.Command, error) { 134 | return nil, errors.New("some error") 135 | }, 136 | expected: 1, 137 | }, 138 | { 139 | name: "on run error, it should return exit code 1", 140 | args: []string{"valid"}, 141 | argParser: func(s []string) (cmd.Command, error) { 142 | return MockCommand{ 143 | runFunc: func(ctx context.Context) error { 144 | return errors.New("whopsidops, error ojoj..!") 145 | }, 146 | }, nil 147 | }, 148 | expected: 1, 149 | }, 150 | { 151 | name: "on success, error code 0", 152 | args: []string{"valid"}, 153 | argParser: func(s []string) (cmd.Command, error) { 154 | return MockCommand{ 155 | runFunc: func(ctx context.Context) error { 156 | return nil 157 | }, 158 | }, nil 159 | }, 160 | expected: 0, 161 | }, 162 | } 163 | 164 | for _, tt := range tests { 165 | t.Run(tt.name, func(t *testing.T) { 166 | result := run(context.Background(), tt.args, tt.argParser) 167 | if result != tt.expected { 168 | t.Errorf("run() = %v, want %v", result, tt.expected) 169 | } 170 | }) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Function to get the latest release download URL for the specified OS and architecture 4 | get_latest_release_url() { 5 | repo="baalimago/wd-41" 6 | os="$1" 7 | arch="$2" 8 | 9 | # Fetch the latest release data from GitHub API 10 | release_data=$(curl -s "https://api.github.com/repos/$repo/releases/latest") 11 | 12 | # Extract the asset URL for the specified OS and architecture 13 | download_url=$(echo "$release_data" | grep "browser_download_url" | grep "$os" | grep "$arch" | cut -d '"' -f 4) 14 | 15 | echo "$download_url" 16 | } 17 | 18 | # Detect the OS 19 | case "$(uname)" in 20 | Linux*) 21 | os="linux" 22 | ;; 23 | Darwin*) 24 | os="darwin" 25 | ;; 26 | *) 27 | echo "Unsupported OS: $(uname)" 28 | exit 1 29 | ;; 30 | esac 31 | 32 | # Detect the architecture 33 | arch=$(uname -m) 34 | case "$arch" in 35 | x86_64) 36 | arch="amd64" 37 | ;; 38 | armv7*) 39 | arch="arm" 40 | ;; 41 | aarch64|arm64) 42 | arch="arm64" 43 | ;; 44 | i?86) 45 | arch="386" 46 | ;; 47 | *) 48 | echo "Unsupported architecture: $arch" 49 | exit 1 50 | ;; 51 | esac 52 | 53 | printf "detected os: '%s', arch: '%s'\n" "$os" "$arch" 54 | 55 | # Get the download URL for the latest release 56 | printf "finding asset url..." 57 | download_url=$(get_latest_release_url "$os" "$arch") 58 | printf "OK!\n" 59 | 60 | # Download the binary 61 | tmp_file=$(mktemp) 62 | 63 | printf "downloading binary..." 64 | if ! curl -s -L -o "$tmp_file" "$download_url"; then 65 | echo 66 | echo "Failed to download the binary." 67 | exit 1 68 | fi 69 | printf "OK!\n" 70 | 71 | printf "setting file executable file permissions..." 72 | # Make the binary executable 73 | 74 | if ! chmod +x "$tmp_file"; then 75 | echo 76 | echo "Failed to make the binary executable. Try running the script with sudo." 77 | exit 1 78 | fi 79 | printf "OK!\n" 80 | 81 | # Move the binary to standard XDG location and handle permission errors 82 | INSTALL_DIR=$HOME/.local/bin 83 | # If run as 'sudo', install to /usr/local/bin for systemwide use 84 | if [ -x /usr/bin/id ]; then 85 | if [ `/usr/bin/id -u` -eq 0 ]; then 86 | INSTALL_DIR=/usr/local/bin 87 | fi 88 | fi 89 | 90 | if ! mv "$tmp_file" $INSTALL_DIR/wd-41; then 91 | echo "Failed to move the binary to $INSTALL_DIR/wd-41, see error above. Try making sure you have write permission there, or run 'mv $tmp_file '." 92 | exit 1 93 | fi 94 | 95 | echo "wd-41 installed successfully in $INSTALL_DIR, try it out with 'wd-41 h'" 96 | --------------------------------------------------------------------------------