Releases: zopdev/static-server
Release list
v1.0.0
What's Changed
Features
Markdown content negotiation (Accept: text/markdown)
A request whose Accept header names text/markdown is served the markdown sibling of the page when one exists — /about returns about.md instead of about/index.html. Static site generators already emit these files, so no build change is needed in the site being served; if the .md isn't there, the request falls through to HTML untouched.
Agents otherwise have to download the entire HTML document just to discover the <link rel="alternate"> inside it. Measured on a real build of zop.dev, the markdown ran 15–96× smaller than the page it replaces (e.g. /changelog/v1-33-0: 145,354 B → 1,512 B).
| Behavior | Detail |
|---|---|
| What counts as asking | The media type must be named. Browsers send */*;q=0.8, which matches text/markdown by the letter of RFC 9110 — matching wildcards would serve raw source to every visitor. text/x-markdown is accepted too. |
| Preference respected | q-values are honored: text/html, text/markdown;q=0.1 still gets HTML. |
| Which URLs negotiate | Extensionless routes only. / is always index.html; a path with an extension resolves identically for every client. |
Vary: Accept |
Set on responses that can actually depend on Accept — negotiable routes, their SPA fallback, and every miss. Not set on assets, so a CDN isn't asked to fragment its cache on a header that cannot change the response. A Vary your _headers declares is preserved, not replaced. |
| Misses | A client that asked for markdown gets a 138-byte markdown 404 naming /sitemap.xml and /llms.txt, instead of an HTML error shell it cannot parse (a real site's 404 page measured 144,188 B). |
Content-Type |
Set explicitly for negotiated responses only — the distroless base has no /etc/mime.types and Go's MIME table has no .md entry. A directly requested .md is left alone, so existing .md links don't turn into download prompts. |
_headers file support
If the published directory contains a _headers file — the Netlify / Cloudflare Pages convention — its rules are parsed once at startup and applied to matching responses. Generators emit this file expecting the host to honor it; a host that ignores it fails silently while the file looks authoritative in the repo.
Measured against live zop.dev, which had shipped a 2,437-byte _headers for months: not one rule was in effect — no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy, no Permissions-Policy, and content-hashed /_astro/* bundles served with no Cache-Control at all.
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
/_astro/*
Cache-Control: public, max-age=31536000, immutable
| Behavior | Detail |
|---|---|
| Matching | * matches any run of characters, including /. Patterns are anchored, so /docs/* does not match /other/docs/x. Netlify's :placeholder syntax is not supported. |
| Precedence | Every matching rule contributes, in file order, so a later specific block overrides an earlier catch-all. |
| Malformed lines | Skipped individually — one bad rule doesn't cost the file its other headers. |
| Errors and misses | Rules apply to 404s and the SPA fallback too: a 404 that leaks framing protection is as exploitable as a 200 that does. Cache-Control is the exception — withdrawn from a miss, and from a delegated response returning 4xx/5xx, so a /* cache rule cannot pin a file that is merely un-propagated mid-deploy. The SPA fallback keeps its caching, being a real route rather than a miss. |
/.well-known/ |
Still delegated to the framework so ACME challenges resolve untouched, but the header rules now apply to it — previously that early return skipped them, so a site declaring X-Frame-Options for /* got it everywhere except there. |
No _headers file means no rules and no change, so this is inert for sites that don't ship one.
Bug fixes
Config values that were set but empty fell back to nothing
gofr's GetOrDefault only falls back when a key is absent. The shipped configs/.env declares STATIC_DIR_PATH= and DEFAULT_EXTENSION= with empty values, so a deployment supplying STATIC_DIR_PATH through the environment received "" and silently rooted every lookup at the process working directory — serving pages while loading zero _headers rules.
| Container shape | Before | After |
|---|---|---|
default ./static |
22 rules ✅ | 22 ✅ |
config-file STATIC_DIR_PATH |
22 rules ✅ | 22 ✅ |
env-var STATIC_DIR_PATH |
0 rules, pages still served ❌ | 22 ✅ |
The default shape was unaffected — by luck, not design. Found by running the actual distroless image rather than a native binary.
Internals
- The startup log now names the resolved directory alongside the rule count:
0 rulesis normal for a site without the file and otherwise indistinguishable from a misrooted path. - The response writer is wrapped only on the
/.well-known/path. Doing it on the main serving path would hide net/http'sio.ReaderFromfromhttp.ServeFileand cost every static file its sendfile fast path.
Verification
go test -racegreen. 20Accept-parsing cases; the_headerssuite is built on a verbatim excerpt of the real zop.dev file.- Mutation-tested — the suite goes red on each of 16 seeded defects, including treating
*/*as markdown, ignoring q-values, applying only the first matching rule, dropping pattern anchoring, and removing theCache-Controlwithdrawal. - Run under
golang:1.26withmedia-typesinstalled, so/etc/mime.typesreally containedtext/markdown— the platform shape that behaves differently from a dev Mac and from the shipped distroless image. - Verified in the real image (
gcr.io/distroless/static-debian12) against a 5,978-page build: 16/16 on Content-Type, every_headersrule including on 404s, negotiation,Vary, and the markdown 404. - Regression diff of base vs. patched binaries over path ×
Acceptcombinations, comparing status, Content-Type,Vary,Cache-Control,X-Frame-Options, Content-Length and body SHA-256: the only changed-or-removed field across the whole matrix is the one intended negotiation. Every other delta is an addition of a header the site's own_headersfile declares.
Compatibility
Fully backward compatible, and both features are gated on something the published directory opts into:
- Markdown negotiation is inert until the served site emits
.mdsiblings — a build that doesn't is byte-for-byte unchanged. _headersdoes nothing until a directory ships the file.- Reverse proxies still win. If something in front of this server sets the same header, its value is what reaches the client — ingress-nginx sends
Strict-Transport-Securityby default, for instance. Headers with no proxy counterpart take effect immediately.
Upgrading from v0.0.9 with no site changes behaves identically.
Full Changelog: v0.0.9...v1.0.0
v0.0.9
What's Changed
Features
SPA fallback mode (SPA_MODE)
When SPA_MODE=true, extensionless routes that don't match a file now serve index.html with status 200, enabling client-side routing (React Router, Vue Router, etc.) without duplicating index.html as 404.html. Defaults to false — existing setups are unaffected.
Configurable default extension (DEFAULT_EXTENSION)
Replaces the hardcoded .html auto-resolution. Set DEFAULT_EXTENSION=.json (or any extension) to serve extensionless requests from a different file type. Defaults to .html to preserve existing behavior.
Note: only one extension is tried per request. Setting
DEFAULT_EXTENSIONto a non-html value disables.htmlauto-resolution. See README for interaction withSPA_MODE.
Multi-platform Docker images (amd64 + arm64)
Images now build for both linux/amd64 and linux/arm64 via Docker buildx with native Go cross-compilation. Apple Silicon users no longer need emulation.
Bug fixes
404 response headers no longer flushed prematurely
The previous handler called w.WriteHeader(404) before http.ServeFile, which flushed headers early and prevented ServeFile from setting Content-Type, Content-Length, and caching headers. A new statusOverrideWriter lets ServeFile set all headers normally and only overrides the status code.
Broader Stat error handling
ServeHTTP now treats any Stat error (permissions, I/O, symlink loop) as "not serveable" and routes to the 404 / SPA fallback, instead of falling through to http.ServeFile with a broken path.
Dockerfile builds all Go files
Build step changed from go build main.go to go build . so handler.go and future files are included.
Refactor & internals
- Middleware extracted into testable
staticFileHandlerstruct backed by GoFr'sfile.FileSysteminterface. - Per-request
regexp.MatchStringreplaced withfilepath.Ext— eliminates regex compilation on every request. SPA_MODEparsing usesstrconv.ParseBool.- Tests replaced non-deterministic
go main()+time.Sleepwithhttptest.NewServerfor proper lifecycle.
Dependencies & tooling
- GoFr upgraded to v1.56.0; replaced deprecated
file.Newwithfile.NewLocalFileSystem. - GitHub Actions:
checkoutv4→v6,setup-gov4→v6 (caching default),golangci-lint-actionv8→v9.
Compatibility
Fully backward compatible. SPA_MODE defaults to false and DEFAULT_EXTENSION defaults to .html, so services upgrading from v0.0.8 with no env changes behave identically.
Full Changelog: v0.0.8...v0.0.9
v0.0.8
What's Changed
Features
1. Config File Hydration
When the CONFIG_FILE_PATH environment variable is set, the server replaces any ${VAR} placeholders in that file at startup using values from the environment (including .env files). The file is rewritten in-place before serving begins.
This is useful for injecting runtime configuration into static front-end apps without rebuilding them.
If any placeholders have no matching environment variable, the server still writes the file (substituting empty strings for missing values) and logs an error listing the unresolved variables.
Full Changelog: v0.0.7...v0.0.8
v0.0.7
Release Notes
🚀 New Features
- Added ARM64 support enabling the image to run on ARM-based systems (e.g., Apple Silicon, AWS Graviton).
🔧 Improvements
- Updated GitHub Action versions to the latest supported releases for improved stability and security.
- Added a workflow step to print the image registry path, making it easier to identify the published image location during CI runs.
📦 Compatibility
- Images are now available for both
amd64andarm64architectures.
v0.0.6
🚀 Release Notes — v0.0.6
🔧 CI & Build
- Migrated to
golangci-lintv2 with caching - Improved GitHub Actions workflow (faster builds, cleaner job separation)
- Docker push now configurable and skips gracefully if credentials are missing
🔐 Security
- Improved path handling logic
- Added tests for path validation
- Updated gosec configuration to handle false positives
🐳 Docker
- Switched to distroless base image
- Built fully static binary (
CGO_ENABLED=0, stripped flags)
📘 Docs
- Updated README to reference
v0.0.6tag
Focus: CI reliability, performance improvements, container hardening, and minor security refinements.
v0.0.5
Fixes:
Corrected File Serving Priority Between filePath.html and filePath/index.html
- The server now correctly prioritizes filePath.html if it exists.
- If filePath.html does not exist but filePath/ is a directory, the server serves filePath/index.html.
- If neither exists, the server returns 404 Not Found.
v0.0.4
v0.0.3
What's Changed
- Exempt gofr's well-known endpoint in static server middleware.
Full Changelog: v0.0.2...v0.0.3
v0.0.2
Enhancements
-
Removed File Mapping
The server no longer creates a map of files, streamlining the file-serving process. -
404 Status for Missing Files
If a requested file is not found, the server now returns a 404 status code, improving error handling. -
Default Directory Update
The default directory for serving static files has been changed tostatic.- To customize this directory set the
STATIC_DIR_PATHenvironment variable.
- To customize this directory set the
v0.0.1
Release Notes
- Create Static File Server which reads from website directory with the following support.
- Re-direct to 404.html endpoint when webpage does not exist.
- Add .html endpoint to urls which does not have any extension.