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:
- Percent-decode first โ otherwise
%2e%2e%2fwalks straight past a check that only understands literal dots. - Reject NUL and backslash โ
safe.mp4\x00../../etc/passwdpasses a naive suffix check and opens something else;..\..\xtraverses on Windows but survives a slash-only cleaner. - 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 theAuthorizecallback ever learn an attack was attempted. No real client puts..in a media URL, so failing closed costs nothing. - Hide dotfiles by default, so a stray
.envin the media root is unreachable. - Extension allow-list, so a new dangerous type cannot silently become servable.
- 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
| Field | Default | Notes |
|---|---|---|
Root | โ | required; resolved absolutely, symlinks evaluated once at mount |
Prefix | /videos | route is Prefix + "/*filepath" |
Extensions | common video + HLS/DASH | allow-list, case-insensitive |
AllowHidden | false | dotfiles stay unreachable |
FollowSymlinks | false | target must still resolve inside Root |
ChunkSize | 256 KiB | bytes per write |
MaxChunkSize | 4 MiB | cap for open-ended ranges; negative disables |
CacheControl | public, max-age=86400 | "-" omits the header |
AllowedOrigins | none | "*" allows any; Vary: Origin always sent |
Authorize | nil | runs on the clean name, before any disk access |
Secret | nil | enables signed URLs |
OnError | nil | receives the internal error, never sent to clients |
Status codes
| Code | When |
|---|---|
| 200 | only an empty file, with Content-Length: 0 |
| 206 | every other success โ a valid Range, and also a request with no Range or a malformed one (see below) |
| 304 | If-None-Match / If-Modified-Since hit |
| 403 | signature missing, invalid or expired; Authorize refused |
| 404 | missing, traversal, hidden, wrong type, directory, symlink |
| 416 | well-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.
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.