├── .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 | [](https://godoc.org/github.com/tj/spa)
16 | 
17 | 
18 | 
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://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(), "