Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73b9ee022
|
||
|
|
bbc588d933
|
||
|
|
e4f3ba1882
|
||
|
|
b708ca527b
|
||
|
|
09b5133f91
|
||
|
|
59ff806a3a
|
||
|
|
6511d84f15
|
@@ -6,6 +6,90 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
||||
|
||||
---
|
||||
|
||||
## [1.7.0] — 2026-08-14
|
||||
|
||||
Minor — HTTP behaviour: caching, a strict SPA fallback, security headers, and a non-root image.
|
||||
|
||||
### Added
|
||||
|
||||
- **Per-file `Cache-Control`.** `index.html` and service-worker files (`ngsw.json`, `ngsw-worker.js`,
|
||||
`sw.js`, `service-worker.js`, `safety-worker.js`, `workbox-*.js`) are `no-cache`; content-hashed
|
||||
assets are `public, max-age=31536000, immutable`; everything else is `public, max-age=3600`.
|
||||
Correct for Vite, CRA and Angular output. Closes, at the HTTP layer, the same stale-release trap
|
||||
the v1.6.0 image fix closed at the container layer.
|
||||
- **`X-Content-Type-Options: nosniff`** on every response.
|
||||
- **Non-root container.** The image adds an unprivileged `spa` user and runs as it — a static binary
|
||||
serving read-only files on `:8080` needs no privilege.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The `index.html` fallback is now scoped to navigation requests.** A path that does not resolve to
|
||||
a file is served `index.html` only when it has no file extension and the client accepts HTML
|
||||
(`text/html` / `*/*`). A missing asset (`/main.js`) or a typed non-HTML client now gets **404**
|
||||
instead of `index.html`, so a broken deploy fails loudly rather than shipping the SPA shell as
|
||||
JavaScript (`Unexpected token '<'`).
|
||||
- Bumped einherjar dependencies to v1.7.0.
|
||||
|
||||
### Docs
|
||||
|
||||
- README documents the caching table and the navigation-scoped fallback, and notes that **Angular**
|
||||
must `COPY dist/<project>/browser/` (not `dist/`) — the application builder nests `index.html`
|
||||
under `browser/`.
|
||||
|
||||
## [1.6.0] — 2026-08-14
|
||||
|
||||
Minor — coordinated framework release (lockstep versioning). No changes to this module's own API.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Dropped `VOLUME ["/srv/www"]` from the base image.** The declaration created an anonymous
|
||||
volume at the SPA root that `docker compose up` reuses across container recreation, so a
|
||||
rebuild silently kept serving the previous bundle (`up -d --build` exited 0 while shipping the
|
||||
old build; on a PWA the stale `ngsw.json` then poisoned the service-worker cache). Consumers
|
||||
could not un-declare an inherited `VOLUME` and were stuck with `--renew-anon-volumes` forever.
|
||||
Removing it makes the headline `COPY dist/ /srv/www/` case deterministic, and the runtime
|
||||
bind-mount case (`-v /path:/srv/www`) is unaffected. *The `v1.6.0` image was rebuilt and
|
||||
re-pushed with this fix.* Consumers with an already-poisoned anonymous volume need a one-time
|
||||
`-V` (or `docker volume rm`) to shed it.
|
||||
|
||||
### Changed
|
||||
|
||||
- Bumped einherjar dependencies to v1.6.0. The framework-wide change in this release is
|
||||
`web`'s new `httputil.Bind` / `httputil.BindEmpty` request-binding adapters.
|
||||
|
||||
## [1.5.0] — 2026-08-09
|
||||
|
||||
Minor — coordinated framework version alignment. No code or API changes in this module.
|
||||
|
||||
### Changed
|
||||
|
||||
- Bumped `contracts`, `core` to v1.5.0.
|
||||
|
||||
## [1.4.0] — 2026-08-09
|
||||
|
||||
Minor — coordinated framework release: dependency refresh + lockstep version alignment.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed dependencies to their latest minor/patch where available.
|
||||
- Bumped `contracts`, `core` to v1.4.0.
|
||||
|
||||
## [1.3.0] — 2026-08-08
|
||||
|
||||
Minor — coordinated framework version alignment. No code or API changes in this module.
|
||||
|
||||
### Changed
|
||||
|
||||
- Bumped `contracts`, `core` to v1.3.0.
|
||||
|
||||
## [1.2.0] — 2026-08-08
|
||||
|
||||
Minor — coordinated framework version alignment. No code or API changes in this module.
|
||||
|
||||
### Changed
|
||||
|
||||
- Bumped `contracts`, `core` to v1.2.0.
|
||||
|
||||
## [1.1.3] — 2026-08-08
|
||||
|
||||
Patch — coordinated framework version alignment.
|
||||
|
||||
+14
-3
@@ -10,7 +10,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
./cmd/spa-server
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S spa && adduser -S -G spa -H -s /sbin/nologin spa
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/spa-server .
|
||||
|
||||
@@ -25,7 +26,17 @@ LABEL org.opencontainers.image.base.name="alpine:3.21"
|
||||
LABEL org.opencontainers.image.base.digest="$BASE_DIGEST"
|
||||
LABEL dev.nochebuena.healthz="/health"
|
||||
|
||||
# Mount your SPA dist/ here, or COPY it in a downstream Dockerfile.
|
||||
VOLUME ["/srv/www"]
|
||||
# Provide your SPA build at /srv/www — COPY dist/ in a downstream Dockerfile, or
|
||||
# bind-mount it at runtime (-v /path/to/dist:/srv/www). No VOLUME is declared here
|
||||
# on purpose: a VOLUME at this path creates an anonymous volume that `docker compose
|
||||
# up` reuses across container recreation, shadowing a freshly COPY'd bundle with the
|
||||
# previous build — a silent stale deploy (worse for PWAs, whose service worker then
|
||||
# caches the stale ngsw.json). A runtime bind-mount works with or without it, and a
|
||||
# child image cannot un-declare an inherited VOLUME, so it must not be declared at all.
|
||||
|
||||
# Drop root: the server is a static binary serving read-only files on a >1024 port,
|
||||
# so it needs no privilege. COPY'd assets are world-readable, so the unprivileged
|
||||
# user reads /srv/www without a chown.
|
||||
USER spa
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/spa-server"]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# einherjar/spa-server
|
||||
|
||||
[](https://code.nochebuena.dev/einherjar/spa-server)
|
||||
[](https://code.nochebuena.dev/einherjar/spa-server)
|
||||
[](LICENSE)
|
||||
[](https://go.dev)
|
||||
|
||||
> A shield wall holds because every warrior knows their position. The SPA asks only for the wall — not for every soldier's name.
|
||||
|
||||
`code.nochebuena.dev/einherjar/spa-server` is a container-first HTTP server for single-page applications and progressive web apps. It serves static assets directly and falls back to `index.html` for any path that does not resolve to a file on disk — the standard SPA routing contract.
|
||||
`code.nochebuena.dev/einherjar/spa-server` is a container-first HTTP server for single-page applications and progressive web apps. It serves static assets directly and falls back to `index.html` for navigation routes that do not resolve to a file — the standard SPA routing contract — while a missing asset returns `404` rather than the HTML shell.
|
||||
|
||||
The module ships as a ready-to-use Docker base image. Deploying a SPA to a container requires a single `COPY` instruction. No nginx, no custom configuration, no index.html redirect logic to maintain.
|
||||
|
||||
@@ -15,11 +15,23 @@ The module ships as a ready-to-use Docker base image. Deploying a SPA to a conta
|
||||
## Container usage
|
||||
|
||||
```dockerfile
|
||||
FROM code.nochebuena.dev/einherjar/spa-server:v1.0.0
|
||||
FROM code.nochebuena.dev/einherjar/spa-server:v1.7.0
|
||||
COPY dist/ /srv/www/
|
||||
```
|
||||
|
||||
That is the complete Dockerfile for a production SPA container.
|
||||
That is the complete Dockerfile for a production SPA container. It runs as an
|
||||
unprivileged user and serves `/srv/www` on port 8080.
|
||||
|
||||
`COPY dist/ /srv/www/` is correct for **Vite** and **CRA**, which emit `index.html`
|
||||
at the root of `dist/`. **Angular**'s application builder instead emits
|
||||
`dist/<project>/browser/` (with `index.html` inside `browser/`, next to
|
||||
`3rdpartylicenses.txt`), so copy that subdirectory — otherwise no `index.html` lands
|
||||
at the root and every request falls through to a 404:
|
||||
|
||||
```dockerfile
|
||||
FROM code.nochebuena.dev/einherjar/spa-server:v1.7.0
|
||||
COPY dist/my-app/browser/ /srv/www/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -51,8 +63,34 @@ That is the complete Dockerfile for a production SPA container.
|
||||
|---|---|
|
||||
| `/app.js` — file exists | Served directly with correct `Content-Type` |
|
||||
| `/assets/logo.png` — file exists | Served directly |
|
||||
| `/dashboard` — no matching file | `index.html` served (SPA router handles it) |
|
||||
| `/dashboard` — no file, `Accept: text/html` | `index.html` served (SPA router handles it) |
|
||||
| `/` — directory | `index.html` served (directory listing is disabled) |
|
||||
| `/main.js` — **no file, has an extension** | **`404`** — a missing asset is not masked as HTML |
|
||||
| `/api/x` — no file, `Accept: application/json` | **`404`** — a non-HTML client is not handed `index.html` |
|
||||
|
||||
The fallback to `index.html` is deliberately scoped to **navigation** requests (no
|
||||
file extension, and `Accept` includes `text/html` or `*/*`). A request for a missing
|
||||
asset returns `404` instead of `index.html`, so a broken deploy fails at the first
|
||||
request rather than delivering the SPA shell as JavaScript (`Unexpected token '<'`).
|
||||
|
||||
---
|
||||
|
||||
## Caching
|
||||
|
||||
`Cache-Control` is set per file, correct for Vite, CRA and Angular alike:
|
||||
|
||||
| File | `Cache-Control` |
|
||||
|---|---|
|
||||
| `index.html` | `no-cache` — revalidated every load, so a new deploy is never masked |
|
||||
| Service workers (`ngsw.json`, `ngsw-worker.js`, `sw.js`, `service-worker.js`, `safety-worker.js`, `workbox-*.js`) | `no-cache` — a client is never pinned to a superseded release |
|
||||
| Content-hashed assets (`main.4f8a2b1c.js`, `index-DkJf3x9a.js`, …) | `public, max-age=31536000, immutable` |
|
||||
| Everything else (`favicon.ico`, `icons/`, verbatim assets) | `public, max-age=3600` |
|
||||
|
||||
A hashed filename is detected by a trailing hash-like token (≥8 alphanumerics
|
||||
including a digit) before the extension — a heuristic whose only failure mode is a
|
||||
missed year-long cache, never staleness.
|
||||
|
||||
Every response also carries `X-Content-Type-Options: nosniff`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@ module code.nochebuena.dev/einherjar/spa-server
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
code.nochebuena.dev/einherjar/contracts v1.1.3
|
||||
code.nochebuena.dev/einherjar/core v1.1.3
|
||||
code.nochebuena.dev/einherjar/contracts v1.7.0
|
||||
code.nochebuena.dev/einherjar/core v1.7.0
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
code.nochebuena.dev/einherjar/contracts v1.1.3 h1:rBtQUVCeqaIKMcG1+R0ndHM/4cRm3XTFnVNzFTbf1QU=
|
||||
code.nochebuena.dev/einherjar/contracts v1.1.3/go.mod h1:ccltUtrFb5+MEJdkx2VVEUL+xC5pupVlVVsMM8AlCWI=
|
||||
code.nochebuena.dev/einherjar/core v1.1.3 h1:MjWUA/hJ5IOosACh0in9xzFZ7jc1TTO0YEHh1xQVuOg=
|
||||
code.nochebuena.dev/einherjar/core v1.1.3/go.mod h1:xkI2uQ4S0FQHbFiawTUFgJdG5jbivcZpGFddHxQe8Q0=
|
||||
code.nochebuena.dev/einherjar/contracts v1.7.0 h1:yhbtmvE8u6KXcuG95p888+4tDIiTXDf5z/siKrjGbrc=
|
||||
code.nochebuena.dev/einherjar/contracts v1.7.0/go.mod h1:ccltUtrFb5+MEJdkx2VVEUL+xC5pupVlVVsMM8AlCWI=
|
||||
code.nochebuena.dev/einherjar/core v1.7.0 h1:gdjNEgO8E/ALppyBldj27iKQQlWbL/OWV6asdxWLXqU=
|
||||
code.nochebuena.dev/einherjar/core v1.7.0/go.mod h1:IvSCG7XL4gNyoylwlClKj3jepWBLAKb2QKAgumTRkug=
|
||||
|
||||
@@ -35,11 +35,22 @@ func (s *Server) OnInit() error {
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.cfg.Port),
|
||||
Handler: mux,
|
||||
Handler: securityHeaders(mux),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// securityHeaders applies response headers that should hold for every route.
|
||||
// X-Content-Type-Options: nosniff stops a browser from MIME-sniffing a response
|
||||
// into an executable type — the safety net for a missing asset that slips through
|
||||
// as HTML, or any upstream that mislabels a Content-Type.
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// OnStart begins serving HTTP requests in a background goroutine.
|
||||
// The TCP listener binds synchronously so a port conflict surfaces immediately.
|
||||
func (s *Server) OnStart() error {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package spaserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// securityHeaders must stamp nosniff on every response, regardless of the route.
|
||||
func TestSecurityHeaders_Nosniff(t *testing.T) {
|
||||
h := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
|
||||
}
|
||||
}
|
||||
+103
-5
@@ -3,7 +3,9 @@ package spa
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.nochebuena.dev/einherjar/contracts/logging"
|
||||
)
|
||||
@@ -15,8 +17,14 @@ type handler struct {
|
||||
}
|
||||
|
||||
// NewHandler returns an http.Handler that serves static files from staticDir.
|
||||
// Requests for paths that exist on disk are served directly.
|
||||
// Any other path receives index.html, delegating routing to the SPA.
|
||||
//
|
||||
// A path that resolves to a file on disk is served directly, with a Cache-Control
|
||||
// header chosen from its name (see [cacheControl]). A path that does not resolve to
|
||||
// a file follows the SPA routing contract: a browser navigation receives index.html
|
||||
// so the client-side router can handle the route, while a request for a missing
|
||||
// asset (a path with a file extension, or a non-HTML client) receives 404 — so a
|
||||
// misconfigured deploy fails loudly at the first request instead of returning
|
||||
// index.html as JavaScript ("Unexpected token '<'").
|
||||
func NewHandler(logger logging.Logger, staticDir string) http.Handler {
|
||||
return &handler{
|
||||
logger: logger,
|
||||
@@ -29,8 +37,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// http.Dir.Open sanitises the path, preventing directory traversal.
|
||||
f, err := http.Dir(h.staticDir).Open(r.URL.Path)
|
||||
if err != nil {
|
||||
// Path does not exist — hand off to the SPA router.
|
||||
http.ServeFile(w, r, filepath.Join(h.staticDir, "index.html"))
|
||||
h.serveFallback(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -38,9 +45,100 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
stat, err := f.Stat()
|
||||
if err != nil || stat.IsDir() {
|
||||
// Directory listing is disabled; treat directories as SPA routes.
|
||||
http.ServeFile(w, r, filepath.Join(h.staticDir, "index.html"))
|
||||
h.serveFallback(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Existing file: set Cache-Control from its name, then delegate to FileServer,
|
||||
// which fills Content-Type, Last-Modified, range support and 304s — but never
|
||||
// Cache-Control, so ours stands.
|
||||
w.Header().Set("Cache-Control", cacheControl(path.Base(r.URL.Path)))
|
||||
h.fs.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// serveFallback applies the SPA routing contract for a path that is not a file.
|
||||
func (h *handler) serveFallback(w http.ResponseWriter, r *http.Request) {
|
||||
if looksLikeFile(r.URL.Path) || !acceptsHTML(r) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// index.html must always be revalidated so a new deploy is picked up.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, filepath.Join(h.staticDir, "index.html"))
|
||||
}
|
||||
|
||||
// looksLikeFile reports whether the last path segment carries a file extension —
|
||||
// e.g. /main.4f8a2b1c.js is an asset request, /dashboard is a route.
|
||||
func looksLikeFile(urlPath string) bool {
|
||||
return path.Ext(path.Base(path.Clean(urlPath))) != ""
|
||||
}
|
||||
|
||||
// acceptsHTML reports whether the client will take an HTML document. A browser
|
||||
// navigation sends "text/html"; "*/*" and an absent header are treated as willing
|
||||
// (curl, health probes). A typed non-HTML Accept (e.g. application/json) is not —
|
||||
// such a request for a missing route gets 404 rather than an HTML body.
|
||||
func acceptsHTML(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
return accept == "" ||
|
||||
strings.Contains(accept, "text/html") ||
|
||||
strings.Contains(accept, "*/*")
|
||||
}
|
||||
|
||||
// cacheControl chooses a Cache-Control value for a static file by name, correct for
|
||||
// Vite, CRA and Angular output alike:
|
||||
//
|
||||
// - index.html and service-worker files → no-cache (revalidate every load, so a
|
||||
// new release is never masked by a cached shell or a stale ngsw.json).
|
||||
// - content-hashed filenames → one year, immutable (the hash changes when the
|
||||
// content does, so the URL is safe to cache forever).
|
||||
// - everything else → one hour (favicon.ico, icons/ and other verbatim assets are
|
||||
// not hashed; a year would be a footgun when they change under the same name).
|
||||
func cacheControl(name string) string {
|
||||
if name == "index.html" || isServiceWorker(name) {
|
||||
return "no-cache"
|
||||
}
|
||||
if isHashed(name) {
|
||||
return "public, max-age=31536000, immutable"
|
||||
}
|
||||
return "public, max-age=3600"
|
||||
}
|
||||
|
||||
// isServiceWorker reports whether name is a service-worker or its manifest, across
|
||||
// the common toolchains: Angular (ngsw.json, ngsw-worker.js, safety-worker.js),
|
||||
// CRA (service-worker.js), hand-rolled (sw.js) and Vite-PWA/Workbox (workbox-*.js).
|
||||
// These must never be cached, or a client can get pinned to a superseded release.
|
||||
func isServiceWorker(name string) bool {
|
||||
switch name {
|
||||
case "ngsw.json", "ngsw-worker.js", "safety-worker.js", "service-worker.js", "sw.js":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(name, "workbox-")
|
||||
}
|
||||
|
||||
// isHashed reports whether name ends in a content-hash token: a run of at least 8
|
||||
// alphanumerics containing a digit, set off by '.', '-' or '_' right before the
|
||||
// extension (main.4f8a2b1c.js, index-DkJf3x9a.js, styles-4NDEUD5S.css). It is a
|
||||
// heuristic — a false negative only costs a revalidation, never staleness — so it
|
||||
// leans conservative (the digit requirement keeps it off plain words like about.js).
|
||||
func isHashed(name string) bool {
|
||||
base := strings.TrimSuffix(name, path.Ext(name))
|
||||
i := strings.LastIndexAny(base, ".-_")
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
token := base[i+1:]
|
||||
if len(token) < 8 {
|
||||
return false
|
||||
}
|
||||
hasDigit := false
|
||||
for _, c := range token {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
hasDigit = true
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDigit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package spa
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"code.nochebuena.dev/einherjar/contracts/logging"
|
||||
"code.nochebuena.dev/einherjar/core/logz"
|
||||
)
|
||||
|
||||
func discardLogger() logging.Logger { return logz.New(logz.Config{Writer: io.Discard}) }
|
||||
|
||||
// newTestHandler builds a handler over a temp dir seeded with one file of each
|
||||
// caching class plus index.html.
|
||||
func newTestHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range map[string]string{
|
||||
"index.html": "<!doctype html><title>app</title>",
|
||||
"main.4f8a2b1c.js": "console.log(1)",
|
||||
"favicon.ico": "icon",
|
||||
"ngsw.json": "{}",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return NewHandler(discardLogger(), dir)
|
||||
}
|
||||
|
||||
func get(h http.Handler, target, accept string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
if accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestCacheControl(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
cases := []struct {
|
||||
target, want string
|
||||
}{
|
||||
{"/main.4f8a2b1c.js", "public, max-age=31536000, immutable"}, // content-hashed
|
||||
{"/favicon.ico", "public, max-age=3600"}, // verbatim, not hashed
|
||||
{"/ngsw.json", "no-cache"}, // service-worker manifest
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := get(h, c.target, "*/*")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: status = %d, want 200", c.target, rec.Code)
|
||||
continue
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != c.want {
|
||||
t.Errorf("%s: Cache-Control = %q, want %q", c.target, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexIsNoCache(t *testing.T) {
|
||||
// The SPA shell (served for a navigation route) must revalidate every load.
|
||||
rec := get(newTestHandler(t), "/dashboard", "text/html")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
|
||||
t.Errorf("index Cache-Control = %q, want no-cache", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallback_RouteServesIndex(t *testing.T) {
|
||||
rec := get(newTestHandler(t), "/deep/route", "text/html,application/xhtml+xml")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (index.html for a route)", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); body == "" || body[0] != '<' {
|
||||
t.Errorf("expected index.html body, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallback_MissingAssetIs404(t *testing.T) {
|
||||
// A missing file with an extension must not be masked as HTML.
|
||||
rec := get(newTestHandler(t), "/chunk.9f9f9f9f.js", "*/*")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a missing asset", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallback_NonHTMLAcceptIs404(t *testing.T) {
|
||||
// A typed non-HTML client asking for a missing route gets 404, not the shell.
|
||||
rec := get(newTestHandler(t), "/api/thing", "application/json")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a non-HTML client", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallback_RootServesIndex(t *testing.T) {
|
||||
rec := get(newTestHandler(t), "/", "text/html")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 for /", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHashed(t *testing.T) {
|
||||
hashed := []string{"main.4f8a2b1c.js", "index-DkJf3x9a.js", "styles-4NDEUD5S.css", "app.a1b2c3d4.mjs"}
|
||||
plain := []string{"favicon.ico", "index.html", "logo.png", "about.js", "main.js", "vendor.css"}
|
||||
for _, n := range hashed {
|
||||
if !isHashed(n) {
|
||||
t.Errorf("isHashed(%q) = false, want true", n)
|
||||
}
|
||||
}
|
||||
for _, n := range plain {
|
||||
if isHashed(n) {
|
||||
t.Errorf("isHashed(%q) = true, want false", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user