Skip to content

Commit

Permalink
add support for serving static files with dynamic applications. Closes
Browse files Browse the repository at this point in the history
  • Loading branch information
tj committed Mar 16, 2018
1 parent 5b43ef8 commit 33ac8f3
Show file tree
Hide file tree
Showing 13 changed files with 197 additions and 62 deletions.
5 changes: 0 additions & 5 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,6 @@ func (c *Config) Default() error {
return errors.Wrap(err, ".inject")
}

// default .static
if err := c.Static.Default(); err != nil {
return errors.Wrap(err, ".static")
}

// default .error_pages
if err := c.ErrorPages.Default(); err != nil {
return errors.Wrap(err, ".error_pages")
Expand Down
10 changes: 2 additions & 8 deletions config/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,9 @@ import (
type Static struct {
// Dir containing static files.
Dir string `json:"dir"`
}

// Default implementation.
func (s *Static) Default() error {
if s.Dir == "" {
s.Dir = "."
}

return nil
// Prefix is an optional URL prefix for serving static files.
Prefix string `json:"prefix"`
}

// Validate implementation.
Expand Down
27 changes: 26 additions & 1 deletion docs/04-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,38 @@ By default the current directory (`.`) is served, however you can change this us
}
```

Note that `static.dir` only tells Up which directory to serve – it does not exclude other files from the directory – see [Ignoring Files](#configuration.ignoring_files). For example you may want an `.upignore` containing:
Note that `static.dir` only tells Up which directory to serve – it does not exclude other files from the deployment – see [Ignoring Files](#configuration.ignoring_files). For example you may want an `.upignore` containing:

```
*
!public/**
```

### Dynamic Apps

If your project is not strictly static, for example a Node.js web app, you may omit `type` and add static file serving simply by defining `static` as shown below. With this setup Up will serve the file if it exists, before passing control to your application.

```json
{
"name": "app",
"static": {
"dir": "public"
}
}
```

By default there is no prefix, so `GET /index.css` will resolve to `./public/index.css`, however, you may specify a prefix such as "/static/" for `GET /static/index.css` to ensure static files never conflict with your app's routes:

```json
{
"name": "app",
"static": {
"dir": "public",
"prefix": "/static/"
}
}
```

## Environment Variables

The `environment` object may be used for plain-text environment variables. Note that these are not encrypted, and are stored in up.json which is typically committed to GIT, so do not store secrets here.
Expand Down
1 change: 1 addition & 0 deletions handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func FromConfig(c *up.Config) (http.Handler, error) {
// New handler complete with all Up middleware.
func New(c *up.Config, h http.Handler) (http.Handler, error) {
h = poweredby.New("up", h)
h = static.NewDynamic(c, h)

h, err := headers.New(c, h)
if err != nil {
Expand Down
44 changes: 0 additions & 44 deletions handler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,47 +209,3 @@ func TestHandler_spa(t *testing.T) {
assert.Equal(t, "bar css\n", res.Body.String())
})
}

func BenchmarkHandler(b *testing.B) {
b.ReportAllocs()

b.Run("static server", func(b *testing.B) {
os.Chdir("testdata/basic")

c, err := up.ReadConfig("up.json")
assert.NoError(b, err, "read config")

h := newHandler(b, c)

b.ResetTimer()
b.SetParallelism(80)

b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
h.ServeHTTP(res, req)
}
})
})

b.Run("node server relay", func(b *testing.B) {
os.Chdir("testdata/basic")

c, err := up.ReadConfig("up.json")
assert.NoError(b, err, "read config")

h := newHandler(b, c)

b.ResetTimer()
b.SetParallelism(80)

b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
h.ServeHTTP(res, req)
}
})
})
}
56 changes: 56 additions & 0 deletions http/static/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package static

import (
"net/http"
"os"
"path/filepath"
"strings"

"github.com/apex/up"
)
Expand All @@ -11,3 +14,56 @@ import (
func New(c *up.Config) http.Handler {
return http.FileServer(http.Dir(c.Static.Dir))
}

// NewDynamic static handler for dynamic apps.
func NewDynamic(c *up.Config, next http.Handler) http.Handler {
prefix := normalizePrefix(c.Static.Prefix)
dir := c.Static.Dir

if dir == "" {
return next
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var skip bool
path := r.URL.Path

// prefix
if prefix != "" {
if strings.HasPrefix(path, prefix) {
path = strings.Replace(path, prefix, "/", 1)
} else {
skip = true
}
}

// convert
path = filepath.FromSlash(path)

// file exists, serve it
if !skip {
file := filepath.Join(dir, path)
info, err := os.Stat(file)
if !os.IsNotExist(err) && !info.IsDir() {
http.ServeFile(w, r, file)
return
}
}

// delegate
next.ServeHTTP(w, r)
})
}

// normalizePrefix returns a prefix path normalized with leading and trailing "/".
func normalizePrefix(s string) string {
if !strings.HasPrefix(s, "/") {
s = "/" + s
}

if !strings.HasSuffix(s, "/") {
s = s + "/"
}

return s
}
104 changes: 100 additions & 4 deletions http/static/static_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package static

import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
Expand All @@ -11,10 +13,10 @@ import (
)

func TestStatic_defaults(t *testing.T) {
os.Chdir("testdata")
defer os.Chdir("..")
os.Chdir("testdata/static")
defer os.Chdir("../..")

c := &up.Config{Name: "app"}
c := &up.Config{Name: "app", Type: "static"}
assert.NoError(t, c.Default(), "default")
assert.NoError(t, c.Validate(), "validate")
test(t, c)
Expand All @@ -23,8 +25,9 @@ func TestStatic_defaults(t *testing.T) {
func TestStatic_dir(t *testing.T) {
c := &up.Config{
Name: "app",
Type: "static",
Static: config.Static{
Dir: "testdata",
Dir: "testdata/static",
},
}

Expand Down Expand Up @@ -79,3 +82,96 @@ func test(t *testing.T, c *up.Config) {
assert.Equal(t, "", res.Body.String())
})
}

func TestStatic_dynamic(t *testing.T) {
c := &up.Config{
Name: "app",
Static: config.Static{
Dir: "testdata/dynamic/public",
},
}

assert.NoError(t, c.Default(), "default")
assert.NoError(t, c.Validate(), "validate")

h := NewDynamic(c, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, ":)")
}))

t.Run("file", func(t *testing.T) {
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/css/style.css", nil)

h.ServeHTTP(res, req)

assert.Equal(t, 200, res.Code)
assert.Equal(t, "text/css; charset=utf-8", res.Header().Get("Content-Type"))
assert.Equal(t, "body { background: whatever }\n", res.Body.String())
})

t.Run("missing", func(t *testing.T) {
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/notfound", nil)

h.ServeHTTP(res, req)

assert.Equal(t, 200, res.Code)
assert.Equal(t, "text/plain; charset=utf-8", res.Header().Get("Content-Type"))
assert.Equal(t, ":)\n", res.Body.String())
})
}

func TestStatic_dynamicPrefix(t *testing.T) {
c := &up.Config{
Name: "app",
Static: config.Static{
Dir: "testdata/dynamic/public",
Prefix: "/public",
},
}

assert.NoError(t, c.Default(), "default")
assert.NoError(t, c.Validate(), "validate")

h := NewDynamic(c, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, ":)")
}))

t.Run("/", func(t *testing.T) {
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/index.html", nil)

h.ServeHTTP(res, req)

assert.Equal(t, 200, res.Code)
assert.Equal(t, ":)\n", res.Body.String())
})

t.Run("file", func(t *testing.T) {
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/public/css/style.css", nil)

h.ServeHTTP(res, req)

assert.Equal(t, 200, res.Code)
assert.Equal(t, "text/css; charset=utf-8", res.Header().Get("Content-Type"))
assert.Equal(t, "body { background: whatever }\n", res.Body.String())
})

t.Run("missing", func(t *testing.T) {
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/public/notfound", nil)

h.ServeHTTP(res, req)

assert.Equal(t, 200, res.Code)
assert.Equal(t, ":)\n", res.Body.String())
})
}

func TestNormalizePrefix(t *testing.T) {
assert.Equal(t, `/public/`, normalizePrefix(`public`))
assert.Equal(t, `/public/`, normalizePrefix(`public/`))
assert.Equal(t, `/public/`, normalizePrefix(`/public`))
assert.Equal(t, `/public/`, normalizePrefix(`/public/`))
}
8 changes: 8 additions & 0 deletions http/static/testdata/dynamic/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const http = require('http')
const port = process.env.PORT

http.createServer((req, res) => {
res.setHeader('X-Foo', 'bar')
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.end('Hello World')
}).listen(port)
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions http/static/testdata/static/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
body { background: whatever }
3 changes: 3 additions & 0 deletions http/static/testdata/static/up.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "app"
}

0 comments on commit 33ac8f3

Please sign in to comment.