Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ zig build run-clear -Dtarget=wasm32-emscripten
...
```

To build and serve the examples on http://localhost:8000 run:
```sh
zig build -Dtarget=wasm32-emscripten examples serve-wasm
```

When building with target `wasm32-emscripten` for the first time, the build script will
install and activate the Emscripten SDK into the Zig package cache for the latest SDK
version. There is currently no build system functionality to update or delete the Emscripten SDK
Expand Down
57 changes: 55 additions & 2 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ pub fn build(b: *Build) !void {
mod_sokol.linkLibrary(lib_sokol);

// examples build step
try buildExamples(b, .{
const examples_step = try buildExamples(b, .{
.target = target,
.optimize = optimize,
.backend = sokol_backend,
Expand All @@ -107,6 +107,9 @@ pub fn build(b: *Build) !void {
});
// a manually invoked build step to build auto-docs
buildDocs(b, target);

// web server
buildWebServer(b, optimize, examples_step);
}

// helper function to resolve .auto backend based on target platform
Expand Down Expand Up @@ -484,6 +487,39 @@ fn emSdkSetupStep(b: *Build, emsdk: *Build.Dependency) !?*Build.Step.Run {
}
}

fn buildWebServer(b: *Build, optimize: OptimizeMode, examples_step:*Build.Step) void {
const hosttarget = b.graph.host;

const serve_exe = b.addExecutable(.{
.name = "serve",
.root_module = b.createModule(.{
.root_source_file = b.path("tools/httpserver/serve.zig"),
.target = hosttarget,
.optimize = optimize,
}),
});

const mod_server = b.addModule("StaticHttpFileServer", .{
.root_source_file = b.path("tools/httpserver/root.zig"),
.target = hosttarget,
.optimize = optimize,
});

mod_server.addImport("mime", b.dependency("mime", .{
.target = hosttarget,
.optimize = optimize,
}).module("mime"));

serve_exe.root_module.addImport("StaticHttpFileServer", mod_server);

const run_serve_exe = b.addRunArtifact(serve_exe);
run_serve_exe.addArgs(&.{"zig-out/web", "-p", "8000"});

const serve_step = b.step("serve-wasm", "Serve wasm examples");
serve_step.dependOn(&run_serve_exe.step);
serve_step.dependOn(examples_step);
}

//== DOCUMENTATION =====================================================================================================
fn buildDocs(b: *Build, target: Build.ResolvedTarget) void {
const lib = b.addLibrary(.{
Expand Down Expand Up @@ -539,12 +575,29 @@ const ExampleOptions = struct {
};

// build all examples
fn buildExamples(b: *Build, options: ExampleOptions) !void {
fn buildExamples(b: *Build, options: ExampleOptions) !*Build.Step {
// a top level build step for all examples
const examples_step = b.step("examples", "Build all examples");

inline for (examples) |example| {
try buildExample(b, example, examples_step, options);
}

if (isPlatform(options.target.result, .web)) {
var buf: [256 * examples.len]u8 = undefined;
var str_writer = std.Io.Writer.fixed(&buf);

_ = try str_writer.print("<html><h1>Examples</h1><body><ul>", .{});
inline for (examples) |example| {
_ = try str_writer.print("<li><a href=\"{s}.html\">{s}</a></li>\n", .{example.name, example.name});
}
_ = try str_writer.print("</ul></body></html>", .{});

const wf = b.addWriteFile("index.html", str_writer.buffered());
const index = b.addInstallFile(wf.getDirectory().path(b, "index.html"), "web/index.html");
examples_step.dependOn(&index.step);
}
return examples_step;
}

// build one of the examples
Expand Down
4 changes: 4 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,9 @@
.url = "git+https://github.com/floooh/sokol-tools-bin#f190d396afac7d328ce14d098c9ab7448dfc920b",
.hash = "sokolshdc-0.1.0-r2KZDr4qbAT82i8_UYph-mz3b-ACM3tQr93-rZQ94ghV",
},
.mime = .{
.url = "git+https://github.com/andrewrk/mime.git#4535b74bd5fcaa03e22f2f25af164a4c6253af00",
.hash = "mime-4.0.0-zwmL--0gAACndOVlROQeDnM0chfojNDS8tpohlsBqMNy",
},
},
}
249 changes: 249 additions & 0 deletions tools/httpserver/root.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
/// The key is index into backing_memory, where a HTTP request path is stored.
files: File.Table,
/// Stores file names relative to root directory and file contents, interleaved.
bytes: std.ArrayListUnmanaged(u8),
etag: []const u8,

pub const File = struct {
mime_type: mime.Type,
name_start: usize,
name_len: u16,
/// Stored separately to make aliases work.
contents_start: usize,
contents_len: usize,

pub const Table = std.HashMapUnmanaged(
File,
void,
FileNameContext,
std.hash_map.default_max_load_percentage,
);
};

pub const Options = struct {
allocator: std.mem.Allocator,
/// Must have been opened with iteration permissions.
root_dir: fs.Dir,
cache_control_header: []const u8 = "max-age=0, must-revalidate",
max_file_size: usize = std.math.maxInt(usize),
/// Special alias "404" allows setting a particular file as the file sent
/// for "not found" errors. If this alias is not provided, `serve` returns
/// `error.FileNotFound` instead, leaving the response's state unmodified.
aliases: []const Alias = &.{
.{ .request_path = "/", .file_path = "/index.html" },
.{ .request_path = "404", .file_path = "/404.html" },
},
ignoreFile: *const fn (path: []const u8) bool = &defaultIgnoreFile,
etag: []const u8,

pub const Alias = struct {
request_path: []const u8,
file_path: []const u8,
};

};

pub const InitError = error{
OutOfMemory,
InitFailed,
};

pub fn init(options: Options) InitError!Server {
const gpa = options.allocator;

var it = try options.root_dir.walk(gpa);
defer it.deinit();

var files: File.Table = .{};
errdefer files.deinit(gpa);

var bytes: std.ArrayListUnmanaged(u8) = .{};
errdefer bytes.deinit(gpa);

while (it.next() catch |err| {
log.err("unable to scan root directory: {s}", .{@errorName(err)});
return error.InitFailed;
}) |entry| {
switch (entry.kind) {
.file => {
if (options.ignoreFile(entry.path)) continue;

var file = options.root_dir.openFile(entry.path, .{}) catch |err| {
log.err("unable to open '{s}': {s}", .{ entry.path, @errorName(err) });
return error.InitFailed;
};
defer file.close();

const size = file.getEndPos() catch |err| {
log.err("unable to stat '{s}': {s}", .{ entry.path, @errorName(err) });
return error.InitFailed;
};

if (size > options.max_file_size) {
log.err("file exceeds maximum size: '{s}'", .{entry.path});
return error.InitFailed;
}

const name_len = 1 + entry.path.len;
try bytes.ensureUnusedCapacity(gpa, name_len + size);

// Make the file system path identical independently of
// operating system path inconsistencies. This converts
// backslashes into forward slashes.
const name_start = bytes.items.len;
bytes.appendAssumeCapacity(canonical_sep);
bytes.appendSliceAssumeCapacity(entry.path);
if (fs.path.sep != canonical_sep)
normalizePath(bytes.items[name_start..][0..name_len]);

const contents_start = bytes.items.len;
const contents_len = file.readAll(bytes.unusedCapacitySlice()) catch |e| {
log.err("unable to read '{s}': {s}", .{ entry.path, @errorName(e) });
return error.InitFailed;
};
if (contents_len != size) {
log.err("unexpected EOF when reading '{s}'", .{entry.path});
return error.InitFailed;
}
bytes.items.len += contents_len;

const ext = fs.path.extension(entry.basename);

try files.putNoClobberContext(gpa, .{
.mime_type = mime.extension_map.get(ext) orelse .@"application/octet-stream",
.name_start = name_start,
.name_len = @intCast(name_len),
.contents_start = contents_start,
.contents_len = contents_len,
}, {}, FileNameContext{
.bytes = bytes.items,
});
},
else => continue,
}
}

try files.ensureUnusedCapacityContext(gpa, @intCast(options.aliases.len), FileNameContext{
.bytes = bytes.items,
});

for (options.aliases) |alias| {
const file = files.getKeyAdapted(alias.file_path, FileNameAdapter{
.bytes = bytes.items,
}) orelse {
log.err("alias '{s}' points to nonexistent file '{s}'", .{
alias.request_path, alias.file_path,
});
return error.InitFailed;
};

const name_start = bytes.items.len;
try bytes.appendSlice(gpa, alias.request_path);

if (files.getOrPutAssumeCapacityContext(.{
.mime_type = file.mime_type,
.name_start = name_start,
.name_len = @intCast(alias.request_path.len),
.contents_start = file.contents_start,
.contents_len = file.contents_len,
}, FileNameContext{
.bytes = bytes.items,
}).found_existing) {
log.err("alias '{s}'->'{s}' clobbers existing file or alias", .{
alias.request_path, alias.file_path,
});
return error.InitFailed;
}
}

return .{
.files = files,
.bytes = bytes,
.etag = options.etag,
};
}

pub fn deinit(s: *Server, allocator: std.mem.Allocator) void {
s.files.deinit(allocator);
s.bytes.deinit(allocator);
s.* = undefined;
}

pub const ServeError = error{FileNotFound} || error{HttpExpectationFailed,WriteFailed};

pub fn serve(s: *Server, request: *std.http.Server.Request) ServeError!void {
const path = request.head.target;
const file_name_adapter: FileNameAdapter = .{ .bytes = s.bytes.items };
const file, const status: std.http.Status = b: {
break :b .{
s.files.getKeyAdapted(path, file_name_adapter) orelse {
break :b .{
s.files.getKeyAdapted(@as([]const u8, "404"), file_name_adapter) orelse
return error.FileNotFound,
.not_found,
};
},
.ok,
};
};
const content = s.bytes.items[file.contents_start..][0..file.contents_len];

return request.respond(content, .{
.status = status,
.extra_headers = &.{
.{ .name = "content-type", .value = @tagName(file.mime_type) },
.{ .name = "Etag", .value = s.etag },
.{ .name = "Cross-Origin-Opener-Policy", .value = "same-origin" },
.{ .name = "Cross-Origin-Embedder-Policy", .value = "require-corp" },
},
});
}

pub fn defaultIgnoreFile(path: []const u8) bool {
const basename = fs.path.basename(path);
return std.mem.startsWith(u8, basename, ".") or
std.mem.endsWith(u8, basename, "~");
}

const Server = @This();
const mime = @import("mime");
const std = @import("std");
const fs = std.fs;
const assert = std.debug.assert;
const log = std.log.scoped(.@"static-http-files");

const canonical_sep = fs.path.sep_posix;

fn normalizePath(bytes: []u8) void {
assert(fs.path.sep != canonical_sep);
std.mem.replaceScalar(u8, bytes, fs.path.sep, canonical_sep);
}

const FileNameContext = struct {
bytes: []const u8,

pub fn eql(self: @This(), a: File, b: File) bool {
const a_name = self.bytes[a.name_start..][0..a.name_len];
const b_name = self.bytes[b.name_start..][0..b.name_len];
return std.mem.eql(u8, a_name, b_name);
}

pub fn hash(self: @This(), x: File) u64 {
const name = self.bytes[x.name_start..][0..x.name_len];
return std.hash_map.hashString(name);
}
};

const FileNameAdapter = struct {
bytes: []const u8,

pub fn eql(self: @This(), a_name: []const u8, b: File) bool {
const b_name = self.bytes[b.name_start..][0..b.name_len];
return std.mem.eql(u8, a_name, b_name);
}

pub fn hash(self: @This(), adapted_key: []const u8) u64 {
_ = self;
return std.hash_map.hashString(adapted_key);
}
};
Loading