Every HTML document this server sends now goes out carrying the Hanzo tag, stamped with the site's publishable key. A site gets analytics by being served rather than by remembering to paste something, and one rebuilt from a template cannot lose it. The key lives in the deployment, not in the bundle. Baking it at build time is what let the hanzo.ai apex serve a copy carrying no key at all, and www 0.1.15 serve one whose project had been deleted: in both cases a rebuild was the only way to fix a value that was never part of the build. Only text/html under 1 MB with a </head> is touched, and the bytes are returned unchanged on every other path — no key, an unquotable one, no head, an asset, a file that cannot be read. The file on the backend is never modified; the tag exists only in the response. admit adds api.hanzo.ai to the script-src and connect-src of the policy the response is already carrying, and only when a tag actually went in. The server that puts the script in the page is the only thing that knows it is there, so it is the only thing that can say so — and without it the tag is inert on 22 of the 23 sites this image serves, whose policies name api.hanzo.ai in connect-src for /v1/event but not in script-src. Co-authored-by: Hanzo Dev <[email protected]>
4.7 KiB
Static
Static file server middleware for Hanzo Ingress (Traefik v3).
Serves static files, SPAs, directory listings, and custom error pages directly from the ingress layer — no backend required.
Features
- Static file serving from any directory
- SPA mode (fallback to index for client-side routing)
- Custom index files per directory
- Directory listing with sortable HTML output
- Custom 404 error pages
- Cache-Control headers by file extension
- gzip on every text response (standalone binary), negotiated per request
- Hanzo tag injected into every document served
- Zero dependencies (pure Go stdlib)
Compression
The standalone binary wraps its handler in static.Compress. Text goes out
gzipped when the client says it reads gzip; nothing else changes.
Measured on cloud.hanzo.ai's own export, 27 files, one cold load: 1,586,676 B → 440,328 B, 72% off. The document alone is 198,191 → 20,304 (89%), the stylesheet 146,300 → 20,701 (85%).
What it deliberately does not touch: already-compressed types (woff2, png,
mp4 — the allowlist is by Content-Type), bodies under 1 KB (a sub-MTU frame
costs the same either way and gzip's envelope can make it bigger), Range
requests (a range of the compressed stream is not the range that was asked
for), and HEAD (no body to compress, and its Content-Length must still
describe the resource). Vary: Accept-Encoding is set on every compressible
response, including the ones left identity, so shared caches key correctly.
There is no flag. A correct answer does not need to be opted into.
Weight
~360 lines of Go. No external dependencies. Loaded via Yaegi interpreter at runtime — adds negligible overhead to ingress startup.
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
root |
String | . |
Root directory to serve files from |
enableDirectoryListing |
Boolean | false |
Enable directory browsing |
indexFiles |
Array | ["index.html", "index.htm"] |
Index filenames to try for directories |
spaMode |
Boolean | false |
Redirect all 404s to SPA index |
spaIndex |
String | index.html |
File to serve in SPA mode |
errorPage404 |
String | "" |
Custom 404 page (relative to root) |
cacheControl |
Map | {} |
File extension to Cache-Control value map |
key |
String | "" |
Site publishable key (pk-…). With one, every document served carries the Hanzo tag |
Usage
Embedded in Hanzo Ingress (local plugin)
Add to ingress static config:
experimental:
localPlugins:
static:
moduleName: github.com/hanzoai/static
Place the plugin source in plugins-local/src/github.com/hanzoai/static/.
Then use in dynamic config:
http:
routers:
site:
rule: Host(`example.com`)
service: noop@internal
middlewares:
- static-files
middlewares:
static-files:
plugin:
static:
root: /var/www/html
Remote plugin
experimental:
plugins:
static:
moduleName: github.com/hanzoai/static
version: v0.1.0
Analytics
Give the server the site's publishable key and every HTML document it serves goes out carrying the Hanzo tag:
<script defer src="https://api.hanzo.ai/v1/event.js" data-key="pk-…"></script>
http:
middlewares:
site:
plugin:
static:
root: /var/www/html
key: pk-…
Standalone: --key=pk-… or HANZO_KEY=pk-….
The key belongs to the deployment, not to the bundle — the same build is servable by any site, and a key that changes does not need a rebuild. Nothing on the backend is ever modified; the tag exists only in the response.
Anything that is not a small HTML document with a </head> is served
byte-for-byte, and so is every document while no key is set. Where a
Content-Security-Policy is being sent, https://api.hanzo.ai is added to the
script-src and connect-src that need it — only when a tag was actually
injected, and only that one origin.
SPA mode (React/Vue/Angular)
http:
middlewares:
spa:
plugin:
static:
root: /var/www/app
spaMode: true
spaIndex: index.html
cacheControl:
".html": "no-cache"
".js": "max-age=31536000"
".css": "max-age=31536000"
"*": "max-age=86400"
Development
make lint # golangci-lint
make test # go test -v -cover
License
Apache 2.0 — see LICENSE.
Copyright 2025-2026 Hanzo AI Inc. Based on hhftechnology/statiq (Apache 2.0).