From d6665047d842cc9f61526b6c25f0030d8a825ef2 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Tue, 18 Aug 2026 18:43:55 -0600 Subject: [PATCH] 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. --- CHANGELOG.md | 11 +++++++++++ README.md | 6 +++--- go.mod | 4 ++-- go.sum | 8 ++++---- spa/handler_test.go | 22 ++++++++++++++++++---- spa/mime.go | 24 ++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 spa/mime.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 787112e..d3b8746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 Minor — HTTP behaviour: caching, a strict SPA fallback, security headers, and a non-root image. diff --git a/README.md b/README.md index d965cce..f84fa27 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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) [![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 ```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/ ``` @@ -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: ```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/ ``` diff --git a/go.mod b/go.mod index 2c59bda..6f1018b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module code.nochebuena.dev/einherjar/spa-server go 1.26 require ( - code.nochebuena.dev/einherjar/contracts v1.7.0 - code.nochebuena.dev/einherjar/core v1.7.0 + code.nochebuena.dev/einherjar/contracts v1.7.1 + code.nochebuena.dev/einherjar/core v1.7.1 ) diff --git a/go.sum b/go.sum index 536b4d6..51713fa 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -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= +code.nochebuena.dev/einherjar/contracts v1.7.1 h1:9FEW1HP+osiZT1OQE4gE9QAafIkvLSja0pRmSWX8gyA= +code.nochebuena.dev/einherjar/contracts v1.7.1/go.mod h1:ccltUtrFb5+MEJdkx2VVEUL+xC5pupVlVVsMM8AlCWI= +code.nochebuena.dev/einherjar/core v1.7.1 h1:NJZSx6hBVFUGbvPZ2X/WSiwzI//e5zR4AhSqA2ufvsM= +code.nochebuena.dev/einherjar/core v1.7.1/go.mod h1:hhCB05RXJ5z4yZbfupeil130lAFnEJM/xPdqhfudCVA= diff --git a/spa/handler_test.go b/spa/handler_test.go index e6cdb8a..fc02879 100644 --- a/spa/handler_test.go +++ b/spa/handler_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "code.nochebuena.dev/einherjar/contracts/logging" @@ -20,10 +21,11 @@ func newTestHandler(t *testing.T) http.Handler { t.Helper() dir := t.TempDir() for name, body := range map[string]string{ - "index.html": "app", - "main.4f8a2b1c.js": "console.log(1)", - "favicon.ico": "icon", - "ngsw.json": "{}", + "index.html": "app", + "main.4f8a2b1c.js": "console.log(1)", + "favicon.ico": "icon", + "ngsw.json": "{}", + "manifest.webmanifest": `{"name":"app"}`, } { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { 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) { 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"} diff --git a/spa/mime.go b/spa/mime.go new file mode 100644 index 0000000..6f9d024 --- /dev/null +++ b/spa/mime.go @@ -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) + } +}