go-chi / chi

lightweight, idiomatic and composable router for building Go HTTP services
https://go-chi.io
MIT License
18.71k stars 991 forks source link

Fileserver example for an SPA (Single Page Application) #611

Open frederikhors opened 3 years ago

frederikhors commented 3 years ago

Can I ask you to update the fileserver example for an SPA (Single Page Application) with redirect to index.html avoiding the listing of directories like images?

Chi is amazing but for newbies like me it helps to have updated examples and recipes, like https://github.com/gofiber/recipes.

Thank you with all my heart. ❤️

frederikhors commented 3 years ago

I found this: https://github.com/gorilla/mux#serving-single-page-applications.

Maybe we can update our example using some code from there.

frederikhors commented 3 years ago

I also found: http://kefblog.com/2017-04-07/How-to-serve-static-files-with-custom-not-found-handler.

utamori commented 3 years ago

go-chi & go:embed

https://github.com/shibukawa/spa-go-1.16

pkieltyka commented 3 years ago

Nice! I’ll update when I can.. it’ll be nice to finish the docs PR too and include a section on file serving too, embedding, etc. PRs are welcome :)

ivanduka commented 3 years ago

go-chi & go:embed

https://github.com/shibukawa/spa-go-1.16

This is amazing! Thanks!

vietvudanh commented 3 years ago

go-chi & go:embed

https://github.com/shibukawa/spa-go-1.16

Thank you @utamori.

joh-ku commented 2 years ago

Is there another way of using embed with chi than diverting NotFound as demonstrated here?

With net/http ServerMux I can do something like this:

//go:embed static
var embeddedFS embed.FS

r := http.NewServeMux()
r.Handle("/", http.FileServer(http.FS(embeddedFS)))
err = http.ListenAndServe(":8080", r)
...

This is not possible with chi by simply replacing http.NewServeMux() with chi.NewRouter(). Has anyone a working example?

ivanduka commented 2 years ago

Is there another way of using embed with chi than diverting NotFound as demonstrated here?

With net/http ServerMux I can do something like this:

//go:embed static
var embeddedFS embed.FS

r := http.NewServeMux()
r.Handle("/", http.FileServer(http.FS(embeddedFS)))
err = http.ListenAndServe(":8080", r)
...

This is not possible with chi by simply replacing http.NewServeMux() with chi.NewRouter(). Has anyone a working example?

It works, but the standard library mux has its quirks that you are not aware of, namely it will treat / path as everything that starts with /. With chi you need to be explicit and use /* to achieve the same:

package main

import (
    "embed"
    "log"
    "net/http"

    "github.com/go-chi/chi/v5"
)

//go:embed assets
var embeddedFS embed.FS

func main() {
    r := chi.NewRouter()
    r.Handle("/*", http.FileServer(http.FS(embeddedFS)))
    log.Fatal(http.ListenAndServe(":8080", r))
}
joh-ku commented 2 years ago

Is there another way of using embed with chi than diverting NotFound as demonstrated here? With net/http ServerMux I can do something like this:

//go:embed static
var embeddedFS embed.FS

r := http.NewServeMux()
r.Handle("/", http.FileServer(http.FS(embeddedFS)))
err = http.ListenAndServe(":8080", r)
...

This is not possible with chi by simply replacing http.NewServeMux() with chi.NewRouter(). Has anyone a working example?

It works, but the standard library mux has its quirks that you are not aware of, namely it will treat / path as everything that starts with /. With chi you need to be explicit and use /* to achieve the same:

package main

import (
  "embed"
  "log"
  "net/http"

  "github.com/go-chi/chi/v5"
)

//go:embed assets
var embeddedFS embed.FS

func main() {
  r := chi.NewRouter()
  r.Handle("/*", http.FileServer(http.FS(embeddedFS)))
  log.Fatal(http.ListenAndServe(":8080", r))
}

@ivanduka Awesome, thank you so much!

davidspiess commented 2 years ago

@ivanduka how do you redirect to index.html to implement client side routing?

ivanduka commented 2 years ago

@ivanduka how do you redirect to index.html to implement client side routing?

I assume that you are trying to serve your single-page application.

I declare all my API endpoints and the last thing in my list of routes is a call to NotFound:

router := chi.NewRouter()

// Here I declare all my API endpoints. If none of them are hit, then:

router.NotFound(spa.Handle(app.Assets))

All my static assets are preloaded to a map with content type, etag, etc. precalculated:

type Asset struct {
    Name          string
    FileContent   []byte
    ContentType   string
    Etag          string
    ContentLength string
    LongCache     bool
}

Then I serve it like this:

package spa

import (
    "net/http"
)

const indexHTML = "index.html"

func Handle(assets map[string]Asset) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer r.Body.Close()

        cleanPath := r.URL.Path
        if len(cleanPath) > 0 && cleanPath[0] == '/' {
            cleanPath = r.URL.Path[1:]
        }

        file, ok := assets[cleanPath]
        if !ok {
            file = assets[indexHTML]
        }

        sendFile(w, r.Header.Get("If-None-Match"), file)
    }
}

func sendFile(w http.ResponseWriter, match string, file Asset) {
    if match != "" && match == file.Etag {
        w.WriteHeader(http.StatusNotModified)
        return
    }

    if file.LongCache {
        w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
    }

    w.Header().Set("Etag", file.Etag)
    w.Header().Set("Content-Type", file.ContentType)
    w.Header().Set("Content-Length", file.ContentLength)

    _, _ = w.Write(file.FileContent)
}

Basically, the idea is to serve a file if it exists, and if not - serve index.html

dz0ny commented 1 year ago

Adopted from mux:

package shared

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

// SPAHandler serves a single page application.
func SPAHandler(staticPath string) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Join internally call path.Clean to prevent directory traversal
        path := filepath.Join(staticPath, r.URL.Path)

        // check whether a file exists or is a directory at the given path
        fi, err := os.Stat(path)
        if os.IsNotExist(err) || fi.IsDir() {

            // set cache control header to prevent caching
            // this is to prevent the browser from caching the index.html
            // and serving old build of SPA App
            w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")

            // file does not exist or path is a directory, serve index.html
            http.ServeFile(w, r, filepath.Join(staticPath, "index.html"))
            return
        }

        if err != nil {
            // if we got an error (that wasn't that the file doesn't exist) stating the
            // file, return a 500 internal server error and stop
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        // set cache control header to serve file for a year
        // static files in this case need to be cache busted 
        // (usualy by appending a hash to the filename)
        w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")

        // otherwise, use http.FileServer to serve the static file
        http.FileServer(http.Dir(staticPath)).ServeHTTP(w, r)
    })
}

Usage


router := chi.NewRouter()

// Here I declare all my API endpoints. If none of them are hit, then:

router.NotFound(shared.SPAHandler("./dist"))
starquake commented 1 year ago

Here is a solution I use for go embed:

package site

import (
    "embed"
    "fmt"
    "io/fs"
    "net/http"
    "os"
    "path"
    "strings"
)

//go:embed dist/*
var spaFiles embed.FS

func SPAHandler() http.HandlerFunc {
    spaFS, err := fs.Sub(spaFiles, "dist")
    if err != nil {
        panic(fmt.Errorf("failed getting the sub tree for the site files: %w", err))
    }
    return func(w http.ResponseWriter, r *http.Request) {
        f, err := spaFS.Open(strings.TrimPrefix(path.Clean(r.URL.Path), "/"))
        if err == nil {
            defer f.Close()
        }
        if os.IsNotExist(err) {
            r.URL.Path = "/"
        }
        http.FileServer(http.FS(spaFS)).ServeHTTP(w, r)
    }
}

You can register it with r.Handle("/*", site.SPAHandler()). You can also register it with r.NotFound(site.SPAHandler) but I'm not sure what advantage that would give over just using r.Handle.