oriel

Media server

Stream large local video and audio to the page with HTTP range requests, from a loopback server or the app's own scheme, without loading the files into memory.

oriel.media_server lets the page play and seek in large local files (a multi-GB video, an audio library) without reading them into memory or passing their bytes over the bridge. It serves the files below one directory you choose, answering HTTP range requests (RFC 9110 §14) so <video> and <audio> seek normally. There are two ways to reach them, sharing the same range parser and path checks:

  • a loopback HTTP server (Server) on 127.0.0.1:<port>, which every element can use, <video> and <audio> included;
  • the app’s own scheme: app://app/media/<path> on Linux and macOS, https://app.localhost/media/<path> on Windows (scheme.setRoot), with no port and no CORS, for fetch, XHR and <img>.

For files the app bundles, use its assets (Concepts). For a small file the user picked, read it in a command (Files).

Availability

PlatformLoopback serverSchemePath confinement
Linux✓app://app/media/ (WebKitGTK): fetch, XHR, <img>; not <video>/<audio>. Not with -Dnative_uiopenat2(RESOLVE_BENEATH), Linux 5.6+
Windows✓https://app.localhost/media/ (WebView2 WebResourceRequested)lexical checks, then GetFinalPathNameByHandleW below the root
macOS✓app://app/media/ (WKURLSchemeHandler)fcntl(F_GETPATH) below the root; O_NOFOLLOW_ANY for .refuse_all
Android———
iOS———

The module is the media_server flag, on by default on Linux, Windows and macOS (b.dependency("oriel", .{ .media_server = false }) leaves it out, with its HTTP library). It doesn’t exist on Android and iOS: oriel.media_server.available is false there, and an unguarded use is a build error. No permission is needed.

Quick start

const std = @import("std");
const oriel = @import("oriel");
const app = @import("oriel_app");

var media: oriel.media_server.Server = undefined; // fixed address until stop()

pub fn main(init: std.process.Init) !u8 {
    if (oriel.media_server.available) {
        try media.start(init.io, init.gpa, .{
            .root_dir = "/home/me/Videos", // /<path> maps to files below it
            .port = 17893, // 127.0.0.1 only
        });
    }
    defer if (oriel.media_server.available) media.stop();

    return oriel.main(init, .{ .commands = Commands, .events = Events }, .{
        .app = app.build,
        .window = .{ .title = "Player", .width = 1280, .height = 720 },
    });
}

const Commands = struct {};
const Events = struct {};
<video controls src="http://127.0.0.1:17893/holiday/2025.mkv"></video>

The default content security policy already allows http://127.0.0.1:* for media, images and fetch.

How it works

flowchart LR
  Video["video / audio elements"] -->|"GET Range: bytes=a-b<br/>127.0.0.1:port/path"| Server["Server<br/>(http.zig, own threads)"]
  Fetch["fetch / XHR / img"] -->|"/media/path on the app scheme"| Scheme["media_scheme<br/>(WebKitGTK, WebView2,<br/>WKURLSchemeHandler)"]
  Server --> Check["sanitizePath +<br/>range.parseRange<br/>(media/range.zig)"]
  Scheme --> Check
  Check -->|"openInRoot(root fd, path)"| Open["media/open.zig:<br/>open beneath the root"]
  Open -->|"206 + Content-Range,<br/>streamed in chunks"| Video
  Open --> Fetch

The loopback server (src/modules/media_server.zig) is an http.zig server bound to 127.0.0.1:<port>, started on its own thread by Server.start and joined by stop. It rejects any request whose Host header isn’t 127.0.0.1:<port> or localhost:<port> (403, against DNS rebinding), and answers:

  • GET /ping: {"pong":true,"server":"http.zig"};
  • OPTIONS /*: the CORS preflight (methods GET, HEAD, OPTIONS, headers Range, Content-Type, Accept, max age one day);
  • GET and HEAD /<path>: the file below the root, with Access-Control-Allow-Origin: <allowed_origin>, Accept-Ranges: bytes and Content-Range/Content-Length exposed to the page. The body is streamed in 64 KiB chunks with 64-bit offsets (files over 4 GiB work); a read error or a file that shrinks closes the connection early.

The scheme (src/modules/media_scheme.zig and its backends) is the app’s own URL scheme handler: a request under /media/ goes to media_scheme.handle instead of the assets. On Linux it runs on the main thread and returns a bounded GInputStream that GIO reads from a worker; on macOS the response head is sent at once and a worker thread reads 256 KiB chunks, one in flight, each handed to WebKit on the main thread; on Windows WebView2 gets a read-only IStream over the opened file’s handle (positional reads, no shared file pointer). Nothing serves /media/ until scheme.setRoot is called, and a request then gets 404.

Path checks are the same for both (src/modules/media/range.zig, src/modules/media/open.zig). The request path is percent-decoded; NUL, backslashes, absolute paths and .. segments get 403 (on Windows also drive letters, : and DOS device names). Then the file is opened relative to the root directory’s handle, and the kernel confirms that what was opened lies below the root, so there is no check-then-open race: openat2 with RESOLVE_BENEATH on Linux, F_GETPATH on the opened descriptor on macOS (whose O_RESOLVE_BENEATH isn’t enforced), the final path of the opened handle on Windows. Directories, FIFOs and devices are refused (404); FIFOs are opened non-blocking so they can’t stall the main thread.

Why two transports. The TCP server works with every client, but any local process can connect to the port, and the page needs CORS (the allowed_origin header). The scheme needs neither, but WebKitGTK’s GStreamer player accepts only http(s), blob, data and file URLs, so on Linux <video src="app://..."> fails with MEDIA_ERR_SRC_NOT_SUPPORTED before any request is made. Use the TCP URL for <video> and <audio>.

Examples

Pick a folder, then play from it

var media: oriel.media_server.Server = undefined;
var media_running = false;

const Commands = struct {
    /// Serve `dir` and return the base URL the page plays from.
    pub fn openLibrary(arena: std.mem.Allocator, io: std.Io, args: struct { dir: []const u8 }) ![]const u8 {
        if (media_running) media.stop();
        try media.start(io, std.heap.smp_allocator, .{ .root_dir = args.dir, .port = 17893 });
        media_running = true;
        return std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{17893});
    }
};
const base = await window.oriel.invoke("openLibrary", { dir: folder });
video.src = base + encodeURIComponent("Season 1") + "/" + encodeURIComponent("01.mp4");

Subtitles and thumbnails through the scheme

fn setup() void {
    oriel.media_server.scheme.setRoot("/home/me/Videos", .inside_root) catch |err|
        std.log.warn("media scheme: {s}", .{@errorName(err)});
}
const prefix = window.oriel.platform.os === "windows" ? "https://app.localhost" : "app://app";
const vtt = await (await fetch(`${prefix}/media/movie.vtt`)).text();
thumb.src = `${prefix}/media/movie.jpg`;

Serve to a dev server page

During development the page comes from the dev server, so its origin is the dev server’s: allow that one.

try media.start(io, gpa, .{
    .root_dir = dir,
    .port = 17893,
    .allowed_origin = if (dev_build) "http://localhost:5173" else oriel.security.app_origin,
});

Reference

Server

pub const Server = struct {
    pub fn start(self: *Server, io: std.Io, gpa: std.mem.Allocator, options: Options) !void;
    pub fn stop(self: *Server) void;
};

pub const Options = struct {
    root_dir: []const u8,
    port: u16,
    allowed_origin: []const u8 = oriel.security.app_origin,
    symlink_policy: SymlinkPolicy = .inside_root,
};
FieldMeaning
root_dirThe directory served: /<path> maps to <root_dir>/<path>. Opened once by start (an error if it can’t be).
portThe TCP port on 127.0.0.1. Fixed: pick one your app owns; start fails if it’s taken.
allowed_originAccess-Control-Allow-Origin on file responses: the page’s origin. The default is the app’s (app://app on Linux and macOS, https://app.localhost on Windows); a dev server’s page needs the dev server’s origin.
symlink_policy.inside_root: follow symlinks that stay inside the root; .refuse_all: refuse every symlink.
  • start opens the root, binds and serves from a new thread; self must stay at a fixed address until stop (a global or a heap allocation). It can be called from any thread, before or after oriel.main.
  • stop stops the server, joins its thread and closes the root. Streams in progress end.
ResponseWhen
200No Range, or a malformed one (ignored): the whole file.
206bytes=a-b, bytes=a-, bytes=-n (clamped to the file). Several ranges: only the first is served.
416start past the end, bytes=-0, start > end, or any range of an empty file; Content-Range: bytes */<size>.
403A Host other than 127.0.0.1:<port> / localhost:<port>; a path with NUL, a backslash, .. or an absolute path; a path or symlink that leaves the root; a refused symlink.
404Missing file, directory or other non-regular file; an empty path.
500An unexpected error (on Linux, a kernel without openat2).

Content-Type comes from the extension: mp4 webm mkv mov ogv video, mp3 ogg oga opus wav flac m4a aac audio, vtt (text/vtt), srt (text/plain), json, png, jpg/jpeg, webp; anything else is application/octet-stream.

scheme

pub fn setRoot(path: []const u8, policy: SymlinkPolicy) !void;
pub fn clearRoot() void;
DeclarationMeaning
scheme.setRoot(path, policy)Serve the files below path at <app origin>/media/<path>, replacing any previous root. Main thread (in setup, or a command’s main-thread code). On Linux with -Dnative_ui it returns error.NoWebView.
scheme.clearRoot()Stop serving: requests get 404. Streams already running keep their files.

The scheme answers the same statuses as the server (no Host check and no CORS: it is the page’s own origin). It needs no port, so no other process can reach it.

Other declarations

DeclarationMeaning
available: boolThe module is in this build (false on Android and iOS).
SymlinkPolicyenum { inside_root, refuse_all }.
HandlerThe server’s request handler (ping, handleOptions, handleFile); used by Server.
rangeparseRange(header, size) RangeResult, sanitizePath, mimeForExtension: the pure parts, for code of your own.
openopenRoot, openInRoot, closeRoot: opening files confined to a root, per platform.
selfTest(io, gpa, port) ![]u8GET /ping over real TCP; the body.
check(gpa, ctx)The module’s smoke check: selfTest on ctx.media_port.

Troubleshooting

  • <video> stays black with app://app/media/... on Linux. WebKitGTK’s player can’t load custom schemes (MEDIA_ERR_SRC_NOT_SUPPORTED): use the TCP URL.
  • CORS error in the console. allowed_origin isn’t the page’s origin (in development, the dev server’s).
  • Refused by the content security policy. A custom security.csp must allow http://127.0.0.1:* in media-src (and img-src, connect-src as needed) (Security).
  • 403 for a file that exists. It is reached through a symlink that leaves the root (or any symlink with .refuse_all), or the URL isn’t percent-encoded and contains .. or a backslash. On Windows, a name such as con.mp4 is refused.
  • 500 for every file on Linux. The kernel predates openat2 (5.6).
  • start fails. The root can’t be opened, or the port is in use.

Related

  • Security: the content security policy and the app’s origin.
  • Files: reading files the user picked.
  • Concepts: assets and the app’s scheme.

Search documentation

Type to search the documentation.