fix(mime): register application/manifest+json for .webmanifest (+ webp/avif); v1.7.1

Go's MIME table has no .webmanifest entry, so http.FileServer sniffed the PWA web
app manifest as text/plain. mime.AddExtensionType at package load makes TypeByExtension
authoritative. A correctness nit (the manifest spec parses by content and nosniff does
not reject manifests), but a PWA server should label its manifest correctly.
This commit is contained in:
2026-08-18 18:43:55 -06:00
parent b73b9ee022
commit d6665047d8
6 changed files with 62 additions and 13 deletions
+11
View File
@@ -6,6 +6,17 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [1.7.1] — 2026-08-14
Patch — correct MIME types for PWA assets.
### Fixed
- Register `application/manifest+json` for `.webmanifest` (plus `image/webp` and `image/avif`) at
startup. Without it, Go's content sniffing served the PWA web app manifest as `text/plain`.
Nothing broke in practice — the manifest spec parses by content, and `nosniff` does not reject
manifests — but a PWA-focused server should label its manifest correctly.
## [1.7.0] — 2026-08-14 ## [1.7.0] — 2026-08-14
Minor — HTTP behaviour: caching, a strict SPA fallback, security headers, and a non-root image. Minor — HTTP behaviour: caching, a strict SPA fallback, security headers, and a non-root image.
+3 -3
View File
@@ -1,6 +1,6 @@
# einherjar/spa-server # einherjar/spa-server
[![version](https://img.shields.io/badge/version-v1.7.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/spa-server) [![version](https://img.shields.io/badge/version-v1.7.1-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/spa-server)
[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE) [![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)
[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev) [![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev)
@@ -15,7 +15,7 @@ The module ships as a ready-to-use Docker base image. Deploying a SPA to a conta
## Container usage ## Container usage
```dockerfile ```dockerfile
FROM code.nochebuena.dev/einherjar/spa-server:v1.7.0 FROM code.nochebuena.dev/einherjar/spa-server:v1.7.1
COPY dist/ /srv/www/ COPY dist/ /srv/www/
``` ```
@@ -29,7 +29,7 @@ at the root of `dist/`. **Angular**'s application builder instead emits
at the root and every request falls through to a 404: at the root and every request falls through to a 404:
```dockerfile ```dockerfile
FROM code.nochebuena.dev/einherjar/spa-server:v1.7.0 FROM code.nochebuena.dev/einherjar/spa-server:v1.7.1
COPY dist/my-app/browser/ /srv/www/ COPY dist/my-app/browser/ /srv/www/
``` ```
+2 -2
View File
@@ -3,6 +3,6 @@ module code.nochebuena.dev/einherjar/spa-server
go 1.26 go 1.26
require ( require (
code.nochebuena.dev/einherjar/contracts v1.7.0 code.nochebuena.dev/einherjar/contracts v1.7.1
code.nochebuena.dev/einherjar/core v1.7.0 code.nochebuena.dev/einherjar/core v1.7.1
) )
+4 -4
View File
@@ -1,4 +1,4 @@
code.nochebuena.dev/einherjar/contracts v1.7.0 h1:yhbtmvE8u6KXcuG95p888+4tDIiTXDf5z/siKrjGbrc= code.nochebuena.dev/einherjar/contracts v1.7.1 h1:9FEW1HP+osiZT1OQE4gE9QAafIkvLSja0pRmSWX8gyA=
code.nochebuena.dev/einherjar/contracts v1.7.0/go.mod h1:ccltUtrFb5+MEJdkx2VVEUL+xC5pupVlVVsMM8AlCWI= code.nochebuena.dev/einherjar/contracts v1.7.1/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.1 h1:NJZSx6hBVFUGbvPZ2X/WSiwzI//e5zR4AhSqA2ufvsM=
code.nochebuena.dev/einherjar/core v1.7.0/go.mod h1:IvSCG7XL4gNyoylwlClKj3jepWBLAKb2QKAgumTRkug= code.nochebuena.dev/einherjar/core v1.7.1/go.mod h1:hhCB05RXJ5z4yZbfupeil130lAFnEJM/xPdqhfudCVA=
+14
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"code.nochebuena.dev/einherjar/contracts/logging" "code.nochebuena.dev/einherjar/contracts/logging"
@@ -24,6 +25,7 @@ func newTestHandler(t *testing.T) http.Handler {
"main.4f8a2b1c.js": "console.log(1)", "main.4f8a2b1c.js": "console.log(1)",
"favicon.ico": "icon", "favicon.ico": "icon",
"ngsw.json": "{}", "ngsw.json": "{}",
"manifest.webmanifest": `{"name":"app"}`,
} { } {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -107,6 +109,18 @@ func TestFallback_RootServesIndex(t *testing.T) {
} }
} }
func TestManifestContentType(t *testing.T) {
// Without the mime registration this is sniffed as text/plain; with it the
// PWA manifest carries its registered type.
rec := get(newTestHandler(t), "/manifest.webmanifest", "*/*")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") {
t.Errorf("Content-Type = %q, want application/manifest+json", ct)
}
}
func TestIsHashed(t *testing.T) { func TestIsHashed(t *testing.T) {
hashed := []string{"main.4f8a2b1c.js", "index-DkJf3x9a.js", "styles-4NDEUD5S.css", "app.a1b2c3d4.mjs"} 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"} plain := []string{"favicon.ico", "index.html", "logo.png", "about.js", "main.js", "vendor.css"}
+24
View File
@@ -0,0 +1,24 @@
package spa
import "mime"
// Go's built-in MIME table — and a container image without /etc/mime.types — has no
// entry for these extensions, so http.FileServer falls back to content sniffing and
// mislabels them. The one that matters for a PWA server is the web app manifest,
// sniffed as text/plain instead of application/manifest+json. Registering the types
// once at package load makes mime.TypeByExtension authoritative, so the correct
// Content-Type is sent and no sniffing fallback runs.
//
// This is a correctness nit, not a fix for a live break: the manifest spec parses by
// content (valid JSON) rather than declared type, so browsers accept it either way.
// A PWA-focused server should still label it correctly. .webp/.avif get the same
// treatment for the same reason.
func init() {
for ext, typ := range map[string]string{
".webmanifest": "application/manifest+json",
".webp": "image/webp",
".avif": "image/avif",
} {
_ = mime.AddExtensionType(ext, typ)
}
}