~bigbes/lethe

ref: 0b51b8ee59a86f13b764e305ebffa0c60507ec12 lethe/internal/server/web/embed.go -rw-r--r-- 3.3 KiB
0b51b8ee — Eugene Blikh web: shell, theme, keyboard, stub routes, palette skeleton a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// Package web embeds the compiled SPA assets (produced by `just web-build`)
// into the Go binary via go:embed so the server binary is self-contained.
// At build time `dist/` must be non-empty; the committed placeholder
// index.html satisfies this on a fresh clone. The HTTP handler applies an
// SPA fallback shim: any path that does not match a real file in the
// embedded tree is rewritten to index.html so the client-side router can
// handle it. Real static assets (hashed JS/CSS chunks) are served normally
// with their correct Content-Type; the fallback only fires on 404s.
package web

import (
	"embed"
	"fmt"
	"io/fs"
	"net/http"
)

//go:embed all:dist
var distFS embed.FS

// Handler returns an http.Handler that serves the embedded SPA. Paths that
// exist in the embedded tree are served directly. Paths that do not exist
// fall back to index.html with HTTP 200 so the client-side router handles
// them. If index.html itself cannot be opened from the embedded FS, the
// handler returns 500 with a plain-text error.
//
// Routes beginning with /api/, /healthz, /readyz, or /metrics are NOT
// this handler's concern; they must be mounted before this handler in the
// router so they shadow the catch-all.
func Handler() http.Handler {
	sub, err := fs.Sub(distFS, "dist")
	if err != nil {
		// This should never happen for a hard-coded path; blow up loudly.
		panic(fmt.Sprintf("web: fs.Sub on embedded dist: %v", err))
	}
	fileServer := http.FileServer(http.FS(sub))
	return &spaHandler{fs: sub, fileServer: fileServer}
}

// spaHandler wraps a standard file server with a SPA fallback: if the
// requested path does not exist in the embedded FS, it serves index.html
// instead of a 404.
type spaHandler struct {
	fs         fs.FS
	fileServer http.Handler
}

func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Strip the leading "/" to obtain the relative path within the FS.
	path := r.URL.Path
	if len(path) > 0 && path[0] == '/' {
		path = path[1:]
	}
	if path == "" {
		path = "index.html"
	}

	// Try to open the file to see if it exists.
	f, err := h.fs.Open(path)
	if err != nil {
		// File not found — apply SPA fallback: serve index.html at status 200.
		h.serveIndex(w, r)
		return
	}

	// Stat to confirm it's a regular file (not a directory that would
	// trigger a redirect to path+"/").
	info, statErr := f.Stat()
	_ = f.Close()
	if statErr != nil || info.IsDir() {
		h.serveIndex(w, r)
		return
	}

	// File exists — let the standard file server handle it with correct
	// Content-Type, ETag, and range support.
	h.fileServer.ServeHTTP(w, r)
}

// serveIndex serves the embedded index.html with HTTP 200. It returns 500
// (plain-text) if the embedded FS cannot open the file — this is a build
// invariant violation and must not be silently swallowed.
func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
	// Rewrite the URL to "/" so the file server finds index.html and sets
	// the correct Content-Type header.
	r2 := r.Clone(r.Context())
	r2.URL.Path = "/"
	// Verify index.html is present before delegating; a missing file
	// produces a 500 rather than a misleading file-server 404.
	if _, err := h.fs.Open("index.html"); err != nil {
		http.Error(w, "internal server error: embedded index.html missing", http.StatusInternalServerError)
		return
	}
	h.fileServer.ServeHTTP(w, r2)
}