├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── Readme.md ├── cmd └── spa │ └── main.go ├── go.mod ├── go.sum ├── spa.go ├── spa_test.go └── testdata ├── index.html ├── nested └── some.txt └── style.css /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: tj -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.yml: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | * [ ] I searched to see if the issue already exists. 4 | 5 | ## Description 6 | 7 | Describe the bug or feature. 8 | 9 | ## Steps to Reproduce 10 | 11 | Describe the steps required to reproduce the issue if applicable. 12 | 13 | ## Slack 14 | 15 | Join us on Slack https://chat.apex.sh/ 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please open an issue and discuss changes before spending time on them, unless the change is trivial or an issue already exists. 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Tests 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.13.x] 8 | platform: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v1 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v1 17 | - name: Test 18 | run: go test ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | example 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019 TJ Holowaychuk tj@tjholowaychuk.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Go SPA 2 | 3 | Tiny Single Page Application server for Go with `spa` command-line tool. 4 | 5 | ## Installation 6 | 7 | To install the command-line tool: 8 | 9 | ``` 10 | $ curl -sf https://gobinaries.com/tj/spa/cmd/spa | sh 11 | ``` 12 | 13 | --- 14 | 15 | [![GoDoc](https://godoc.org/github.com/tj/spa?status.svg)](https://godoc.org/github.com/tj/spa) 16 | ![](https://img.shields.io/badge/license-MIT-blue.svg) 17 | ![](https://img.shields.io/badge/status-stable-green.svg) 18 | ![](https://github.com/tj/spa/workflows/Tests/badge.svg) 19 | 20 | ## Sponsors 21 | 22 | This project is sponsored by [CTO.ai](https://cto.ai/), making it easy for development teams to create and share workflow automations without leaving the command line. 23 | 24 | [![](https://apex-software.imgix.net/github/sponsors/cto.png)](https://cto.ai/) 25 | 26 | And my [GitHub sponsors](https://github.com/sponsors/tj): 27 | 28 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/0) 29 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/1) 30 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/2) 31 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/3) 32 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/4) 33 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/5) 34 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/6) 35 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/7) 36 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/8) 37 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/9) 38 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/10) 39 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/11) 40 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/12) 41 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/13) 42 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/14) 43 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/15) 44 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/16) 45 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/17) 46 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/18) 47 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/19) 48 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/20) 49 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/21) 50 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/22) 51 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/23) 52 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/24) 53 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/25) 54 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/26) 55 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/27) 56 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/28) 57 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/29) 58 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/30) 59 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/31) 60 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/32) 61 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/33) 62 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/34) 63 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/35) 64 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/36) 65 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/37) 66 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/38) 67 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/39) 68 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/40) 69 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/41) 70 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/42) 71 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/43) 72 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/44) 73 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/45) 74 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/46) 75 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/47) 76 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/48) 77 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/49) 78 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/50) 79 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/51) 80 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/52) 81 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/53) 82 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/54) 83 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/55) 84 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/56) 85 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/57) 86 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/58) 87 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/59) 88 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/60) 89 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/61) 90 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/62) 91 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/63) 92 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/64) 93 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/65) 94 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/66) 95 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/67) 96 | [](https://sponsors-api-u2fftug6kq-uc.a.run.app/sponsor/profile/68) -------------------------------------------------------------------------------- /cmd/spa/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/go-http-utils/logger" 11 | 12 | "github.com/tj/spa" 13 | ) 14 | 15 | func main() { 16 | addr := flag.String("address", ":3000", "Server bind address.") 17 | 18 | // usage 19 | flag.Usage = func() { 20 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s: [dir]\n", os.Args[0]) 21 | flag.PrintDefaults() 22 | } 23 | 24 | // parse 25 | flag.Parse() 26 | dir := flag.Arg(0) 27 | 28 | if dir == "" { 29 | dir = "." 30 | } 31 | 32 | // server 33 | log.Printf("Serving files from %q", dir) 34 | server := spa.Server{ 35 | Dir: dir, 36 | } 37 | 38 | // logging 39 | h := logger.Handler(server, os.Stdout, logger.CommonLoggerType) 40 | 41 | log.Printf("Listening on %s", *addr) 42 | log.Fatal(http.ListenAndServe(*addr, h)) 43 | } 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tj/spa 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/go-http-utils/logger v0.0.0-20161128092850-f3a42dcdeae6 7 | github.com/tj/assert v0.0.3 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-http-utils/logger v0.0.0-20161128092850-f3a42dcdeae6 h1:R/ypabUA7vskKTRSlgP6rMUHTU6PBRgIcHVSU9qQ6qM= 6 | github.com/go-http-utils/logger v0.0.0-20161128092850-f3a42dcdeae6/go.mod h1:CpBLxS3WrxouNECP/Y1A3i6qDnUYs8BvcXjgOW4Vqcw= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 11 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 12 | github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= 13 | github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c h1:grhR+C34yXImVGp7EzNk+DTIk+323eIUWOmEevy6bDo= 19 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 20 | -------------------------------------------------------------------------------- /spa.go: -------------------------------------------------------------------------------- 1 | // Package spa provides a Single Page Application server. 2 | package spa 3 | 4 | import ( 5 | "net/http" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | ) 10 | 11 | // Server is an http.Handler which serves static files from Dir, 12 | // and reverts to serving index.html for any missing files. 13 | // 14 | // Requests which contain an extension such as "/favicon.ico" are treated as 15 | // requests for a file, yielding 404 instead of serving /index.html. 16 | type Server struct { 17 | Dir string 18 | } 19 | 20 | // ServeHTTP implementation. 21 | func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 22 | name := filepath.Join(s.Dir, path.Clean(r.URL.Path)) 23 | 24 | info, err := os.Stat(name) 25 | 26 | if (os.IsNotExist(err) || !info.Mode().IsRegular()) && path.Ext(name) == "" { 27 | name = filepath.Join(s.Dir, "/index.html") 28 | } 29 | 30 | http.ServeFile(w, r, name) 31 | } 32 | -------------------------------------------------------------------------------- /spa_test.go: -------------------------------------------------------------------------------- 1 | package spa_test 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/tj/assert" 8 | "github.com/tj/spa" 9 | ) 10 | 11 | // Test server. 12 | func TestServer(t *testing.T) { 13 | h := spa.Server{ 14 | Dir: "testdata", 15 | } 16 | 17 | t.Run("with index", func(t *testing.T) { 18 | r := httptest.NewRequest("GET", "/", nil) 19 | w := httptest.NewRecorder() 20 | h.ServeHTTP(w, r) 21 | assert.Equal(t, 200, w.Code) 22 | assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) 23 | assert.Contains(t, w.Body.String(), "

Hello

") 24 | }) 25 | 26 | t.Run("with missing file", func(t *testing.T) { 27 | r := httptest.NewRequest("GET", "/some/random/stuff", nil) 28 | w := httptest.NewRecorder() 29 | h.ServeHTTP(w, r) 30 | assert.Equal(t, 200, w.Code) 31 | assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) 32 | assert.Contains(t, w.Body.String(), "

Hello

") 33 | }) 34 | 35 | t.Run("with file outside of dir", func(t *testing.T) { 36 | r := httptest.NewRequest("GET", "/../go.mod", nil) 37 | w := httptest.NewRecorder() 38 | h.ServeHTTP(w, r) 39 | assert.Equal(t, 400, w.Code) 40 | assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) 41 | assert.Contains(t, w.Body.String(), "invalid URL path\n") 42 | }) 43 | 44 | t.Run("with existing file", func(t *testing.T) { 45 | r := httptest.NewRequest("GET", "/style.css", nil) 46 | w := httptest.NewRecorder() 47 | h.ServeHTTP(w, r) 48 | assert.Equal(t, 200, w.Code) 49 | assert.Equal(t, "text/css; charset=utf-8", w.Header().Get("Content-Type")) 50 | assert.Contains(t, w.Body.String(), "16px Helvetica") 51 | }) 52 | 53 | t.Run("with nested file", func(t *testing.T) { 54 | r := httptest.NewRequest("GET", "/nested/some.txt", nil) 55 | w := httptest.NewRecorder() 56 | h.ServeHTTP(w, r) 57 | assert.Equal(t, 200, w.Code) 58 | assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) 59 | assert.Contains(t, w.Body.String(), "hello") 60 | }) 61 | 62 | t.Run("with directory", func(t *testing.T) { 63 | r := httptest.NewRequest("GET", "/nested", nil) 64 | w := httptest.NewRecorder() 65 | h.ServeHTTP(w, r) 66 | assert.Equal(t, 200, w.Code) 67 | assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) 68 | assert.Contains(t, w.Body.String(), "

Hello

") 69 | }) 70 | 71 | t.Run("with extension", func(t *testing.T) { 72 | r := httptest.NewRequest("GET", "/favicon.ico", nil) 73 | w := httptest.NewRecorder() 74 | h.ServeHTTP(w, r) 75 | assert.Equal(t, 404, w.Code) 76 | assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) 77 | assert.Contains(t, w.Body.String(), "404 page not found\n") 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /testdata/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Hello

8 | 9 | -------------------------------------------------------------------------------- /testdata/nested/some.txt: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /testdata/style.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | font: 16px Helvetica; 4 | padding: 2rem; 5 | } --------------------------------------------------------------------------------