๐ŸŒฌ๏ธ Breeze docs

Video streaming

Byte-range video streaming for Breeze โ€” serves a directory of media files so that browsers can seek, which is the whole difficulty and the reason a plain static file handler isn't enough.

router := breeze.NewRouter()

if err := video.Mount(router, video.Config{Root: "./media"}); err != nil {
	log.Fatal(err)
}

That registers GET and HEAD on /videos/*filepath.

go run ./cmd/video-example

Why a static handler isn't enough

A <video> element never downloads a file in one go. It reads the container header, jumps to wherever the viewer clicked, and reads forward from there. Each jump is a separate request carrying a Range header, and the server has to reply with 206 Partial Content, exactly the bytes asked for, and a Content-Range that describes them.

Ignore Range and the video still plays โ€” which is what makes the bug expensive to find. The scrubber is simply dead: the browser can't ask for the middle of a file it's being handed sequentially.

There's a structural reason this couldn't be a thin wrapper, too. Breeze's HTTPResponse.Bytes() always emits Content-Length: len(Body) and always writes Body, since it's built to describe a complete in-memory response. A 206 whose length is one slice of a file, a 304 with no body, and a multi-write stream whose head goes out before the bytes exist are all outside what it can express โ€” so this package sets ctx.Res = nil, takes over the connection, and serialises its own head.

Memory

One chunk in flight per response โ€” 256 KiB by default, from a pool, returned when the write completes. Ten thousand viewers cost ten thousand chunks, not ten thousand copies of the file.

Players routinely open with Range: bytes=0-, meaning "the rest of the file". Answering literally would pin an entire movie in memory and defeat seeking, so open-ended requests are capped at MaxChunkSize (4 MiB default) โ€” a server is explicitly allowed to return less than was asked for, provided Content-Range says what it actually sent.

Measured on an i5-11400F:

BenchmarkStreamChunk-12     691416 ns/op   6066 MB/s   1655 B/op    4 allocs/op
BenchmarkStreamSeek-12       92186 ns/op   2844 MB/s   1655 B/op    4 allocs/op
BenchmarkNormalize-12          342 ns/op                128 B/op    3 allocs/op
BenchmarkParseRange-12          88 ns/op                 16 B/op    1 allocs/op

Allocations per request do not grow with file size.

Security

The request path is treated as hostile, and the order of the checks is the security property:

  1. Percent-decode first โ€” otherwise %2e%2e%2f walks straight past a check that only understands literal dots.
  2. Reject NUL and backslash โ€” safe.mp4\x00../../etc/passwd passes a naive suffix check and opens something else; ..\..\x traverses on Windows but survives a slash-only cleaner.
  3. Reject any .. segment outright. Cleaning it away would be safe โ€” path.Clean("/"+"../x") gives /x, inside the root โ€” but it silently rewrites an attack into a legitimate-looking lookup, so neither the logs nor the Authorize callback ever learn an attack was attempted. No real client puts .. in a media URL, so failing closed costs nothing.
  4. Hide dotfiles by default, so a stray .env in the media root is unreachable.
  5. Extension allow-list, so a new dangerous type cannot silently become servable.
  6. Only then touch the disk, and prove containment against the symlink-resolved real path โ€” catching the subtle case where every segment is innocent but one is a link out of the tree.

Everything about a file's existence returns 404, never 403, so the filesystem can't be mapped by watching which refusals differ. The real reason goes to OnError and the collector, never to the wire. Header values are stripped of CR/LF at the single point where bytes are serialised, so a filename cannot inject a second response.

Signed URLs

Verified before any filesystem access, so an unauthenticated flood costs no disk I/O:

video.Mount(router, video.Config{
	Root:   "./media",
	Secret: []byte(os.Getenv("VIDEO_SECRET")),
})

url := "/videos/movie.mp4?" + video.Sign(secret, "movie.mp4", 10*time.Minute)

Comparison is constant-time, and the expiry is inside the signed payload so it can't be extended by editing the query string.

Caching

ETag (size + mtime) and Last-Modified on every success. Conditional requests are answered before the file is opened, so a revalidation costs a stat instead of a transfer. If-None-Match takes precedence over If-Modified-Since, since a date is only second-accurate and would keep serving a stale body for up to a second after an edit.

If-Range is honoured, which is what makes a resumed download safe: if the file changed while the client was away, the whole file is sent rather than a slice that would corrupt the client's copy.

Observability

Every request publishes an observability.Signal and a StreamServed event:

events.OnType(func(_ *events.Context, e video.StreamServed) error {
	log.Printf("%s: %d bytes in %v", e.File, e.Bytes, e.Duration)
	return nil
})

A viewer who closes the tab mid-stream is reported as cancelled, not failed. In video that's the most common way a request ends โ€” every seek and every closed tab aborts a transfer in flight โ€” and counting those as errors would make a healthy server look like an outage. Bytes reports what actually left, so a transfer abandoned at 90% is distinguishable from one that never started.

The dashboard's Video tab

coll := dashboard.Install(app, router, dashboard.Config{})
defer coll.AttachVideo(events.Default)()

A separate call from AttachEvents on purpose, so an application with no media pays nothing โ€” the tracker is only allocated when you attach it.

It isn't just the Live Requests feed because streaming breaks the one-row-per-request model. A single viewer emits hundreds of range requests for one file, so that feed would show the same filename repeatedly, interleaved with unrelated traffic โ€” and it can't report throughput at all, since bandwidth is a property of a stream, not of any single request. The Video tab aggregates by file: bytes in flight, MB/s, seeks, disconnects, and errors per title.

Rates are computed over a fixed 10-second window rather than the span between samples, because two requests 3 ms apart would otherwise read as tens of MB/s of "sustained" throughput. 304s are excluded from that window, so a well-cached file doesn't appear slow. The file table is bounded, and idle files are evicted before active ones, so a client probing random paths can't push a live stream off the page. Finished requests remain in the observability ring buffer either way โ€” the tab is a live view, not the historical record.

Configuration

FieldDefaultNotes
Rootโ€”required; resolved absolutely, symlinks evaluated once at mount
Prefix/videosroute is Prefix + "/*filepath"
Extensionscommon video + HLS/DASHallow-list, case-insensitive
AllowHiddenfalsedotfiles stay unreachable
FollowSymlinksfalsetarget must still resolve inside Root
ChunkSize256 KiBbytes per write
MaxChunkSize4 MiBcap for open-ended ranges; negative disables
CacheControlpublic, max-age=86400"-" omits the header
AllowedOriginsnone"*" allows any; Vary: Origin always sent
Authorizenilruns on the clean name, before any disk access
Secretnilenables signed URLs
OnErrornilreceives the internal error, never sent to clients

Status codes

CodeWhen
200only an empty file, with Content-Length: 0
206every other success โ€” a valid Range, and also a request with no Range or a malformed one (see below)
304If-None-Match / If-Modified-Since hit
403signature missing, invalid or expired; Authorize refused
404missing, traversal, hidden, wrong type, directory, symlink
416well-formed but unsatisfiable range; carries Content-Range: bytes */size

A request with no Range still gets 206

RFC 9110 permits serving the whole representation when the client sends no Range โ€” what a static file server does. This mount answers with the first ChunkSize bytes instead, because the alternative for video is streaming an entire movie through one pooled buffer while the viewer waits and can't seek. A malformed Range โ€” which RFC 9110 requires be ignored โ€” takes the same path, so it behaves as if no Range had been sent. Content-Range reports the full size in both cases, so a player learns the duration and continues with explicit ranges.

The consequence is worth knowing: a client that does not understand Content-Range receives a truncated file and no error โ€” curl -O on a 100 MB video saves 256 KB and exits 0. Anything that fetches these URLs as plain downloads (a backup script, a CDN origin pull that ignores partial responses, a scraper) needs to send an explicit Range: bytes=0- and follow Content-Range, or read the file from disk rather than through the mount. Players and browsers are unaffected โ€” they range-request by nature.

Generated documentation for the Breeze framework ยท built with SvelteKit, fully static.