chi icon indicating copy to clipboard operation
chi copied to clipboard

Fileserver example for an SPA (Single Page Application)

Open frederikhors opened this issue 3 years ago • 13 comments

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 avatar Apr 13 '21 14:04 frederikhors

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

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

frederikhors avatar Apr 13 '21 20:04 frederikhors

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

frederikhors avatar Apr 13 '21 20:04 frederikhors

go-chi & go:embed

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

utamori avatar Apr 15 '21 06:04 utamori

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 :)

pkieltyka avatar Apr 15 '21 12:04 pkieltyka

go-chi & go:embed

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

This is amazing! Thanks!

ivanduka avatar May 11 '21 21:05 ivanduka

go-chi & go:embed

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

Thank you @utamori.

vietvudanh avatar Aug 16 '21 08:08 vietvudanh

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?

joh-ku avatar Sep 19 '22 08:09 joh-ku

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 avatar Sep 19 '22 14:09 ivanduka

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!

joh-ku avatar Sep 19 '22 15:09 joh-ku

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

davidspiess avatar Nov 01 '22 09:11 davidspiess

@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

ivanduka avatar Nov 01 '22 19:11 ivanduka

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"))

dz0ny avatar Sep 27 '23 22:09 dz0ny

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.

starquake avatar Nov 09 '23 21:11 starquake