Files
zeekayandHanzo Dev a02b3156cf A site gets analytics by being served
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]>
2026-08-07 16:54:07 -07:00

4.7 KiB

static

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).